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}.
Changes
@@@ -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@@@ -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 forservices.- 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.@@@ -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": {@@@ -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({@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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 });nnew file mode 100644@@@ -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();++})();@@@ -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}`;
@@ -52,6 +52,11 @@ OV_OAUTH_CLIENT_SECRET=
@@ -52,6 +52,11 @@ OV_OAUTH_CLIENT_SECRET=
#COUPONS_SUBMISSIONS_PER_HOUR=10
#COUPONS_SUBMISSIONS_PER_HOUR=10
#COUPONS_SUBMISSIONS_PER_DAY=30
#COUPONS_SUBMISSIONS_PER_DAY=30
#COUPONS_INSTALLS_PER_SUBJECT=10
#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)
# 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
#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
@@ -220,6 +220,21 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
@@ -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
- Submissions: `COUPONS_SUBMISSIONS_PER_HOUR` (10) and `_PER_DAY` (30) per person, ×10 for
services.
services.
- Plus a per-IP ceiling in Express and the nginx zones.
- 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:**
- **Privacy:**
- No response, page or event names a submitter or reporter.
- No response, page or event names a submitter or reporter.
- Report times are published to the hour.
- Report times are published to the hour.
@@ -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": {
@@ -32,13 +32,17 @@ const { createApi } = require('./http/api');
@@ -32,13 +32,17 @@ const { createApi } = require('./http/api');
const { createPublicRoutes } = require('./http/public');
const { createPublicRoutes } = require('./http/public');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createCouponsReadiness } = require('./observability');
const { createCouponsReadiness } = 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, 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 = {}) {
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;
@@ -67,6 +71,9 @@ function createApp(opts = {}) {
@@ -67,6 +71,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'coupons', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'coupons', 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 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(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -28,6 +28,12 @@ function load(env = process.env) {
@@ -28,6 +28,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.COUPONS_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.COUPONS_LIMITS_HOUR, 3000)),
+ },
dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
dbPath: env.COUPONS_DB_PATH || './data/coupons.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -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 };
@@ -34,8 +34,13 @@ const hosts = require('../domain/hosts');
@@ -34,8 +34,13 @@ const hosts = require('../domain/hosts');
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
function createApi(ctx) {
function createApi(ctx) {
- const { config, viewers, merchants, coupons, reports, publication } = ctx;
+ const { config, viewers, merchants, coupons, reports, publication, actorLimits } = ctx;
const router = express.Router();
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 ─────────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────
const noStore = (res) => res.set('Cache-Control', 'private, no-store');
const noStore = (res) => res.set('Cache-Control', 'private, no-store');
@@ -112,7 +117,7 @@ function createApi(ctx) {
@@ -112,7 +117,7 @@ function createApi(ctx) {
// ── Lookup ──────────────────────────────────────────────
// ── Lookup ──────────────────────────────────────────────
router.options(['/merchants/resolve', '/merchants/:id/coupons'], lookupCors);
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>');
if (typeof req.query.host !== 'string') throw new ApiError(400, 'host.required', 'pass ?host=<hostname>');
const found = merchants.resolve(req.query.host);
const found = merchants.resolve(req.query.host);
publicCache(res);
publicCache(res);
@@ -128,19 +133,19 @@ function createApi(ctx) {
@@ -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);
const m = activeMerchant(req.params.id);
publicCache(res);
publicCache(res);
return { merchant: merchantView(m), coupons: coupons.active(m.id).map((c) => coupons.view(c, { merchant: m })) };
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);
const m = activeMerchant(req.params.id);
publicCache(res);
publicCache(res);
return { merchant: merchantView(m) };
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 c = coupons.get(req.params.id);
const m = c && merchants.byId(c.merchant_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');
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) {
@@ -149,13 +154,13 @@ function createApi(ctx) {
}));
}));
// ── Reports ─────────────────────────────────────────────
// ── 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);
noStore(res);
return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
return reports.report(req.viewer, req.params.id, req.body || {}, { channel: 'api', traceparent: req.get('traceparent') });
}, (out) => (out.deduplicated ? 200 : 201)));
}, (out) => (out.deduplicated ? 200 : 201)));
// ── Submissions ─────────────────────────────────────────
// ── 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);
noStore(res);
const v = req.viewer;
const v = req.viewer;
let who;
let who;
@@ -173,7 +178,7 @@ function createApi(ctx) {
@@ -173,7 +178,7 @@ function createApi(ctx) {
}, (out) => (out.duplicate ? 200 : 201)));
}, (out) => (out.duplicate ? 200 : 201)));
// ── Staff and services ──────────────────────────────────
// ── 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);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
const c = coupons.get(req.params.id);
const c = coupons.get(req.params.id);
@@ -183,7 +188,7 @@ function createApi(ctx) {
@@ -183,7 +188,7 @@ function createApi(ctx) {
return { coupon: coupons.view(after) };
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);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
const body = req.body || {};
const body = req.body || {};
@@ -193,7 +198,7 @@ function createApi(ctx) {
@@ -193,7 +198,7 @@ function createApi(ctx) {
return { merchant: { ...merchantView(m), status: m.status } };
return { merchant: { ...merchantView(m), status: m.status } };
}, 201));
}, 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);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
const m = merchants.get(req.params.id);
const m = merchants.get(req.params.id);
@@ -202,7 +207,7 @@ function createApi(ctx) {
@@ -202,7 +207,7 @@ function createApi(ctx) {
return { merchant: { ...merchantView(m), status: m.status } };
return { merchant: { ...merchantView(m), status: m.status } };
}, 201));
}, 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);
noStore(res);
const actor = requireStaffOrService(req);
const actor = requireStaffOrService(req);
const m = merchants.get(req.params.id);
const m = merchants.get(req.params.id);
@@ -33,10 +33,13 @@ const { SCOPES } = require('../auth/capabilities');
@@ -33,10 +33,13 @@ const { SCOPES } = require('../auth/capabilities');
const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
const COUPON_ID_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
function createPublicRoutes(ctx) {
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 router = express.Router();
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
router.use(viewers.pages());
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 ─────────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────
function cacheHeaders(res, { cacheable, robots }) {
function cacheHeaders(res, { cacheable, robots }) {
@@ -168,7 +171,7 @@ function createPublicRoutes(ctx) {
@@ -168,7 +171,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: !flashMsg });
}, { 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;
if (!requireForm(req, res)) return;
const m = merchants.get(req.params.slug);
const m = merchants.get(req.params.slug);
if (!m || m.status !== 'active') return notFound(req, res);
if (!m || m.status !== 'active') return notFound(req, res);
@@ -210,7 +213,7 @@ function createPublicRoutes(ctx) {
@@ -210,7 +213,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: !flashMsg });
}, { 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;
if (!requireForm(req, res)) return;
const back = safeBack(req.body.back, `/c/${req.params.id}`);
const back = safeBack(req.body.back, `/c/${req.params.id}`);
try {
try {
@@ -233,7 +236,7 @@ function createPublicRoutes(ctx) {
@@ -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;
if (!requireForm(req, res)) return;
const b = req.body || {};
const b = req.body || {};
const m = b.merchant ? merchants.get(String(b.merchant)) : null;
const m = b.merchant ? merchants.get(String(b.merchant)) : null;
@@ -269,7 +272,7 @@ function createPublicRoutes(ctx) {
@@ -269,7 +272,7 @@ function createPublicRoutes(ctx) {
}, { cacheable: req.viewer.kind === 'anonymous' });
}, { cacheable: req.viewer.kind === 'anonymous' });
}
}
router.get('/connect-extension', (req, res) => connectPage(req, res, 200));
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;
if (!requireForm(req, res)) return;
try {
try {
const created = installs.create(req.viewer.subject, { label: req.body.label, scopes: req.body.scope_report ? [SCOPES.LOOKUP, SCOPES.REPORT] : [SCOPES.LOOKUP] });
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) {
@@ -322,19 +325,19 @@ function createPublicRoutes(ctx) {
};
};
}
}
router.get('/staff', (req, res) => { if (requireStaff(req, res)) staffPage(req, res); });
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 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 });
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));
store.tx(() => coupons.syncMerchant(m));
return `Added ${m.name}.`;
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);
const m = merchants.get(req.params.id);
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such shop');
const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
const after = moderation.setMerchantStatus(m, req.body.status, { actor: req.viewer.subject });
return `${after.name} is now ${after.status}.`;
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);
const c = coupons.get(req.params.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
const m = merchants.byId(c.merchant_id);
const m = merchants.byId(c.merchant_id);
@@ -342,7 +345,7 @@ function createPublicRoutes(ctx) {
@@ -342,7 +345,7 @@ function createPublicRoutes(ctx) {
coupons.approve(c, { actor: req.viewer.subject });
coupons.approve(c, { actor: req.viewer.subject });
return `Published ${c.code}.`;
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);
const c = coupons.get(req.params.id || req.body.id);
if (!c) throw new ApiError(404, 'coupon.not_found', 'no such code');
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 });
const after = coupons.setStatus(c, req.body.status, { actor: req.viewer.subject, note: req.body.note });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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();
+})();
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,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}`;