openvibers/openvibe.trade Commits

Per-actor rate limits on Trade's /api/v1 and its watchlist, alert and editor 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 and a route's capability guard (for a form, the session and form token) passed, before any work. A person counts as user:usr_…, whether they use their own token or cookie, a first-party service names them in X-OV-Subject, or an app acts for them through on_behalf_of. A service or app acting as itself, such as a feed or OpenVibe.AI, counts by its principal; anyone else by address. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts trade_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · 4658447782 · 12 files, +298 −35

Signed-in API reads take TRADE_LIMITS_MINUTE / TRADE_LIMITS_HOUR, 120 and 3000 (config.actorLimits; config.limits keeps the watchlist and alert quotas). Signed-out reads keep only the per-address limit, because many readers share a carrier or campus address, and a first-party service reading for itself is not counted on reads (the per-address /api/v1 limit bounds it). Each write has its own budget, and a form shares it with the API route that does the same thing:
- Instrument create, edit and aliases: 30 a minute, 300 an hour, since each re-indexes the instrument.
- POST /observations from a feed: 240 and 10 000 (the per-address /api/v1 limit is 240 a minute).
- Context proposals and editor context revisions: 60 and 1200. Context review, publish and retract: 30 and 300, since each changes what readers, feeds, sitemaps and Search see and emits an event.
- Watchlist create: 10 and 100. Rename, delete, and adding or removing instruments: 60 and 600.
- Alert create: 20 and 100, since every rule is evaluated against each new observation and document. Alert delete: 60 and 600.

The per-address limits stay. Health, ready, release.json, release-metrics, metrics, sign-in, the pages and feeds people read, and the signed Events deliveries at /internal/events are never limited per actor. The limit middleware adds no route and no handler name, so the route inventory is unchanged. The test helper takes a log and limitsNow.

test/actor-limits.test.js runs on a fixed limiter clock. The fourth read in a minute is refused with Retry-After 45 while another person passes. A service naming the same person counts against them, a service reading for itself is not counted, signed-out reads from one address all pass, and the next minute reopens. After 10 API watchlists in a minute the form and a service creating for the same person are refused (the budget is shared) and nothing is stored, while another person still creates. Health, ready, release.json, metrics and deliveries never answer 429. Refusals are logged without a token and counted. npm test 13/13.

Changes

ddiff --git a/.env.example b/.env.example
iindex e3c06bc..591bda4 100644
---- a/.env.example
++++ b/.env.example
@@@ -12,6 +12,12 @@ NODE_ENV=development
BASE_URL=http://localhost:4860
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
TRUST_PROXY=2
++# Per-actor limits (server/http/actor-limits.js): API reads one caller (a signed-in person, an app,
++# else an address) may make per minute and per hour; past them 429 rate_limited with Retry-After.
++# Signed-out reads keep the per-address limit only. Writes have their own numbers (README). The
++# per-address limits stay.
++#TRADE_LIMITS_MINUTE=120
++#TRADE_LIMITS_HOUR=3000
# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)
TRADE_DB_PATH=./data/trade.db
ddiff --git a/README.md b/README.md
iindex b684616..c329d80 100644
---- a/README.md
++++ b/README.md
@@@ -281,11 +281,41 @@ registry, atomically. A placeholder never counts as an implemented service.
- **Webhook:** signature v2 only (HMAC over `<timestamp>.<raw body>`, timestamp within the replay
window; a v1-only or stale delivery is refused), inbox per event id, host-local only (nginx returns 404).
- **Abuse:** rate limits on `/auth`, private forms and `/api/v1`, in Express and in the nginx
-- reference; per-person limits of 20 watchlists × 200 instruments and 100 alert rules.
++ reference; per-actor limits (below); per-person limits of 20 watchlists × 200 instruments and 100
++ alert rules.
- **Known gaps:** the advice filter is a phrase guard, not a guarantee (editors remain accountable);
there is no market data source yet; Network does not consume `trade.alert.triggered` yet; Search
documents exist only for instruments with reviewed context.
++### Per-actor limits
++
++`/api/v1` and the watchlist, alert and editor forms also limit who calls them, once `req.viewer` is
++resolved and a route's capability guard (for a form, the session and form token) passed, before any
++work: `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. Counted: a person as
++`user:usr_…` (their own token or cookie, a first-party service naming them in `X-OV-Subject`, or an
++app's `on_behalf_of`); a service or app acting as itself (a feed, OpenVibe.AI) by its principal;
++anyone else by address. Signed-out reads keep only the per-address limit (many readers share a
++carrier or campus address), and a first-party service reading for itself is not counted on reads.
++Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one log line and
++`trade_rate_limited_total{limit,window}`. A form and the API route that do the same thing share one
++budget.
++
++| Routes (API and form) | Per caller, a minute / an hour |
++|---|---|
++| Signed-in API reads | `TRADE_LIMITS_MINUTE` / `TRADE_LIMITS_HOUR` (120 / 3000) |
++| Instrument create, edit, aliases | 30 / 300 |
++| `POST /observations` (a feed) | 240 / 10 000 |
++| Context proposals and editor context revisions | 60 / 1200 |
++| Context review, publish, retract | 30 / 300 |
++| Watchlist create | 10 / 100 |
++| Watchlist rename, delete, add and remove items | 60 / 600 |
++| Alert create | 20 / 100 |
++| Alert delete | 60 / 600 |
++
++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/release-metrics`, `/metrics`,
++sign-in, the pages and feeds people read, and the signed Events deliveries at `/internal/events`.
++`test/actor-limits.test.js`.
++
## Development
```bash
ddiff --git a/package-lock.json b/package-lock.json
iindex 46b9e9b..dd55763 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.49.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 703b3ff..0d4355b 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.49.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 16092c0..dfabc1d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -46,6 +46,7 @@ const { createPrivate } = require('./http/private');
const { createEditor } = require('./http/editor');
const { createDiscovery } = require('./http/discovery');
const { createApi } = require('./http/api');
++const { createActorLimits } = require('./http/actor-limits');
const { define } = require('./http/routes');
const { createTradeReadiness } = require('./observability');
const { createWorker } = require('./worker');
@@@ -54,7 +55,8 @@ 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; default the wall clock) */
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
@@@ -89,6 +91,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'trade', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
++ // Per-actor limits (http/actor-limits.js) on /api/v1 and the forms, counted once each router resolved
++ // req.viewer; the per-address limits below stay.
++ ctx.limits = 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 e8c8255..12ff714 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 their own numbers there.
++ actorLimits: {
++ minute: Math.max(1, int(env.TRADE_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.TRADE_LIMITS_HOUR, 3000)),
++ },
dbPath: env.TRADE_DB_PATH || './data/trade.db',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..c45d6b9
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Per-actor rate limits on /api/v1 and the watchlist, alert and editor forms (roadmap WS-R task 4;
++ * openvibe-sdk/limits).
++ *
++ * The per-address limits in app.js (/auth, /api/v1, /watchlists, /alerts, /editor) stay. These count
++ * requests by who makes them, once req.viewer is resolved (auth/viewer.js) and a route's capability
++ * guard (or, for a form, the session and form token) passed:
++ *
++ * a person user:usr_… (their own token or cookie, a first-party service naming
++ * them in X-OV-Subject, or an app's on_behalf_of)
++ * a service or app acting as its principal (svc:ai, a feed's svc:…, app:app_…)
++ * itself
++ * anyone else ip:<address>
++ *
++ * Reads: a signed-in person's or an app's GET/HEAD under /api/v1 takes TRADE_LIMITS_MINUTE /
++ * TRADE_LIMITS_HOUR (120 and 3000). Signed-out reads keep only the per-address limit (many readers
++ * share a carrier or campus address), and a first-party service reading for itself is not counted on
++ * reads (the per-address /api/v1 limit bounds it). Every write has its own budget below, shared by the
++ * API route and the form that do the same thing. Past a limit the route answers 429 problem+json
++ * `rate_limited` with Retry-After before any work; the refusal is logged once and counted in
++ * trade_rate_limited_total{limit,window}. Counters live in this process: a restart forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /release-metrics, /metrics, sign-in, the pages
++ * and feeds people read, and the signed Events deliveries at /internal/events.
++ */
++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
++
++const FIRST_PARTY = /^svc:/;
++
++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' && v.service) return v.service;
++ if (v.kind === 'user' && v.user && v.user.id != null) return `user:${v.user.id}`;
++ return defaultActor(req);
++}
++
++/** Counted on reads: a signed-in person, or a service or app acting for a person or as a third party. */
++function countedRead(req) {
++ const v = req.viewer;
++ if (!v || v.kind === 'anonymous') return false;
++ if (v.kind === 'service' && !v.subject && FIRST_PARTY.test(String(v.service))) return false;
++ return true;
++}
++
++/**
++ * The writes, each with its numbers per caller (a minute, an hour). A form and the API route that do
++ * the same thing share one budget.
++ */
++const BUDGETS = {
++ // Adding or editing an instrument and its aliases re-indexes it for Search: an editor saves a form
++ // every few seconds at most.
++ 'trade.instrument.manage': { minute: 30, hour: 300 },
++ // A first-party feed records one observation per instrument and metric; each is immutable, may fire
++ // alerts and emits an event. Up to the per-address /api/v1 limit a minute (240), 10 000 an hour.
++ 'trade.observation.write': { minute: 240, hour: 10000 },
++ // An AI workflow proposes one context revision per instrument it worked on (one a second at most);
++ // an editor writes far fewer.
++ 'trade.context.propose': { minute: 60, hour: 1200 },
++ // Reviewing, publishing and retracting context changes what readers, feeds, sitemaps and Search see
++ // and emits an event each.
++ 'trade.context.publish': { minute: 30, hour: 300 },
++ // A person keeps a few watchlists.
++ 'trade.watchlist.create': { minute: 10, hour: 100 },
++ // Renaming, deleting and adding or removing instruments: a few clicks in a row.
++ 'trade.watchlist.update': { minute: 60, hour: 600 },
++ // Every alert rule is evaluated against each new observation and document of its instrument.
++ 'trade.alert.create': { minute: 20, hour: 100 },
++ 'trade.alert.delete': { minute: 60, hour: 600 },
++};
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on a counted
++ * GET/HEAD) and limits.budget(name) (one of BUDGETS).
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'trade_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(`[Trade] limit ${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 function actorReadLimit(req, res, next) {
++ return (req.method === 'GET' || req.method === 'HEAD') && countedRead(req) ? limit(req, res, next) : next();
++ };
++ };
++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
++ limiter.budget = (name) => {
++ const m = budgets.get(name);
++ if (!m) throw new Error(`limits: no budget named ${name}`);
++ return m;
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor, countedRead, BUDGETS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7eca74c..ab0adb8 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -68,6 +68,11 @@ function createApi(ctx) {
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use(function privateApi(req, res, next) { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
++ // Per-actor limits (http/actor-limits.js), once req.viewer is resolved: a signed-in person's or an
++ // app's reads take the defaults (signed-out reads keep the per-address limit only); each write below
++ // names its budget after its capability guard, before its body is read.
++ router.use(ctx.limits.reads('trade.read'));
++ const B = (name) => ctx.limits.budget(name);
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
@@@ -113,7 +118,7 @@ function createApi(ctx) {
};
}));
-- define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
++ define(router, 'post', '/instruments', 'createInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
mustEditor(req);
const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
const i = instruments.create(req.body || {}, by);
@@@ -126,14 +131,14 @@ function createApi(ctx) {
return data;
}));
-- define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
++ define(router, 'patch', '/instruments/:symbol', 'updateInstrument', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
mustEditor(req);
const i = instruments.update(mustInstrument(req), req.body || {});
store.tx(() => indexing.refresh(i, tp(req)));
return { instrument: reading.instrumentDto(i) };
}));
-- define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), jsonBody, run((req) => {
++ define(router, 'post', '/instruments/:symbol/aliases', 'addInstrumentAlias', guard(C.INSTRUMENT_MANAGE), B('trade.instrument.manage'), jsonBody, run((req) => {
mustEditor(req);
const i = mustInstrument(req);
const b = req.body || {};
@@@ -154,7 +159,7 @@ function createApi(ctx) {
// ── Observations (first-party feeds) ────────────────────
-- define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), jsonBody, run((req) => {
++ define(router, 'post', '/observations', 'recordObservation', guard(C.OBSERVATION_WRITE), B('trade.observation.write'), jsonBody, run((req) => {
if (req.viewer.kind !== 'service') throw new ApiError(403, 'observation.services_only', 'Observations come from sources, recorded by a service with trade.observation.write');
const b = req.body || {};
const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);
@@@ -179,14 +184,14 @@ function createApi(ctx) {
define(router, 'get', '/instruments/:symbol/context/input', 'getContextInput', guard(C.CONTEXT_READ), run((req) => context.input(mustInstrument(req))));
-- define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), jsonBody, run((req) => {
++ define(router, 'post', '/instruments/:symbol/context', 'proposeContext', guard(C.CONTEXT_PROPOSE), B('trade.context.propose'), jsonBody, run((req) => {
const i = mustInstrument(req);
const out = context.propose(req.viewer, i, req.body || {});
ctx.outbox.kick();
return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };
}, 201));
-- define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', jsonBody, run((req) => {
++ define(router, 'post', '/instruments/:symbol/context/revisions/:n/review', 'reviewContext', B('trade.context.publish'), jsonBody, run((req) => {
const i = mustInstrument(req);
const b = req.body || {};
const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });
@@@ -202,27 +207,27 @@ function createApi(ctx) {
define(router, 'get', '/watchlists', 'getWatchlists', guard(C.WATCHLIST_READ), run((req) => ({ watchlists: watchlists.forOwner(owner(req)).map((w) => watchlists.dto(w)) })));
-- define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));
++ define(router, 'post', '/watchlists', 'createWatchlist', guard(C.WATCHLIST_CREATE), B('trade.watchlist.create'), jsonBody, run((req) => ({ watchlist: watchlists.dto(watchlists.create(owner(req), req.body || {})) }), 201));
define(router, 'get', '/watchlists/:id', 'getWatchlist', guard(C.WATCHLIST_READ), run((req) => ({ watchlist: watchlists.dto(watchlists.mustOwn(owner(req), req.params.id)) })));
-- define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {
++ define(router, 'patch', '/watchlists/:id', 'renameWatchlist', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {
const w = watchlists.mustOwn(owner(req), req.params.id);
return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };
}));
-- define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), jsonBody, run((req) => {
++ define(router, 'put', '/watchlists/:id/items/:symbol', 'addWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), jsonBody, run((req) => {
const w = watchlists.mustOwn(owner(req), req.params.id);
const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);
return { added, watchlist: watchlists.dto(w) };
}));
-- define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), run((req) => {
++ define(router, 'delete', '/watchlists/:id/items/:symbol', 'removeWatchlistItem', guard(C.WATCHLIST_UPDATE), B('trade.watchlist.update'), run((req) => {
const w = watchlists.mustOwn(owner(req), req.params.id);
return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };
}));
-- define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));
++ define(router, 'delete', '/watchlists/:id', 'deleteWatchlist', guard(C.WATCHLIST_DELETE), B('trade.watchlist.update'), run((req) => ({ deleted: watchlists.remove(watchlists.mustOwn(owner(req), req.params.id)) })));
// ── Alerts (private) ────────────────────────────────────
@@@ -230,13 +235,13 @@ function createApi(ctx) {
define(router, 'get', '/alerts/deliveries', 'getAlertDeliveries', guard(C.ALERT_READ), run((req) => ({ deliveries: alerts.deliveries(owner(req), req.query.limit) })));
-- define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), jsonBody, run((req) => {
++ define(router, 'post', '/alerts', 'createAlertRule', guard(C.ALERT_CREATE), B('trade.alert.create'), jsonBody, run((req) => {
const b = req.body || {};
const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);
return { rule: alerts.dto(rule) };
}, 201));
-- define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));
++ define(router, 'delete', '/alerts/:id', 'deleteAlertRule', guard(C.ALERT_DELETE), B('trade.alert.delete'), run((req) => ({ deleted: alerts.remove(owner(req), req.params.id) })));
router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });
return router;
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 2fb8c3e..92a9b45 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -32,6 +32,9 @@ function createEditor(ctx) {
next();
}
const form = [...common.signedInForm, requireEditor];
++ // Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route that
++ // does the same thing, checked once the session, form token and editor list are.
++ const B = (name) => ctx.limits.budget(name);
function mustInstrument(req) {
const i = instruments.bySymbol(req.params.symbol);
@@@ -53,7 +56,7 @@ function createEditor(ctx) {
common.page(req, res, { title: 'Editor', decision, personal: true, body: views.editorHome({ instruments: all, pending, csrf: common.csrf(req), notice: common.noticeOf(req), kinds: instruments.KINDS }) });
});
-- define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/instruments', 'createInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {
try {
const i = instruments.create(req.body, req.viewer.subject);
ctx.store.tx(() => indexing.refresh(i));
@@@ -75,7 +78,7 @@ function createEditor(ctx) {
});
});
-- define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol', 'saveInstrumentForm', ...form, B('trade.instrument.manage'), (req, res) => {
try {
const i = mustInstrument(req);
const updated = instruments.update(i, { name: req.body.name, kind: req.body.kind, exchange: req.body.exchange, cik: req.body.cik, currency: req.body.currency, status: req.body.status });
@@@ -84,7 +87,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
-- define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol/aliases', 'addAliasForm', ...form, B('trade.instrument.manage'), (req, res) => {
try {
const i = mustInstrument(req);
instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
@@@ -92,7 +95,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
-- define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol/context', 'writeContextForm', ...form, B('trade.context.propose'), (req, res) => {
try {
const i = mustInstrument(req);
const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
@@@ -105,7 +108,7 @@ function createEditor(ctx) {
}
});
-- define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol/context/:n/review', 'reviewContextForm', ...form, B('trade.context.publish'), (req, res) => {
try {
const i = mustInstrument(req);
const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
@@@ -114,7 +117,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
-- define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol/context/:n/publish', 'publishContextForm', ...form, B('trade.context.publish'), (req, res) => {
try {
const i = mustInstrument(req);
context.publish(i, parseInt(req.params.n, 10));
@@@ -122,7 +125,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
});
-- define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, (req, res) => {
++ define(router, 'post', '/editor/i/:symbol/context/retract', 'retractContextForm', ...form, B('trade.context.publish'), (req, res) => {
try {
const i = mustInstrument(req);
context.retract(req.viewer, i);
ddiff --git a/server/http/private.js b/server/http/private.js
iindex 3f9d235..79f1170 100644
---- a/server/http/private.js
++++ b/server/http/private.js
@@@ -23,6 +23,9 @@ const { asApiError } = require('./errors');
function createPrivate(ctx) {
const { instruments, watchlists, alerts, common } = ctx;
const router = express.Router();
++ // Per-actor limits (http/actor-limits.js): each form names its budget, shared with the API route that
++ // does the same thing, checked once the session and form token are.
++ const B = (name) => ctx.limits.budget(name);
const privateDecision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });
function back(req, res, fallback, err) {
@@@ -50,28 +53,28 @@ function createPrivate(ctx) {
});
});
-- define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/watchlists', 'createWatchlistForm', ...common.signedInForm, B('trade.watchlist.create'), (req, res) => {
try {
watchlists.create(req.viewer.subject, { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_created' });
} catch (err) { back(req, res, '/watchlists', err); }
});
-- define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/watchlists/rename', 'renameWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
try {
watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_renamed' });
} catch (err) { back(req, res, '/watchlists', err); }
});
-- define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/watchlists/delete', 'deleteWatchlistForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
try {
watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
common.after(res, '/watchlists', { n: 'watchlist_deleted' });
} catch (err) { back(req, res, '/watchlists', err); }
});
-- define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/watchlists/item', 'addWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
try {
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
const instrument = instrumentFrom(req.body);
@@@ -81,7 +84,7 @@ function createPrivate(ctx) {
} catch (err) { back(req, res, '/watchlists', err); }
});
-- define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/watchlists/item/remove', 'removeWatchlistItemForm', ...common.signedInForm, B('trade.watchlist.update'), (req, res) => {
try {
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
watchlists.drop(w, instruments.bySymbol(req.body.symbol));
@@@ -89,7 +92,7 @@ function createPrivate(ctx) {
} catch (err) { back(req, res, '/watchlists', err); }
});
-- define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/alerts', 'createAlertForm', ...common.signedInForm, B('trade.alert.create'), (req, res) => {
const instrument = instruments.bySymbol(req.body.symbol);
try {
alerts.create(req.viewer.subject, instrument, {
@@@ -104,7 +107,7 @@ function createPrivate(ctx) {
}
});
-- define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, (req, res) => {
++ define(router, 'post', '/alerts/delete', 'deleteAlertForm', ...common.signedInForm, B('trade.alert.delete'), (req, res) => {
try {
alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
common.after(res, '/watchlists', { n: 'alert_deleted' });
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..de6d96f
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * Per-actor rate limits (server/http/actor-limits.js, roadmap WS-R task 4): past its limit one caller
++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while another
++ * caller still passes; the window reopens on the clock. A first-party service naming a person counts
++ * against that person; signed-out reads and a first-party service reading for itself are left to the
++ * per-address limit. A form shares its budget with the API route that does the same thing. Health,
++ * ready, release.json, metrics and the Events deliveries are never limited; refusals are logged (no
++ * token) and counted.
++ */
++const assert = require('assert');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++(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 logs = [];
++ const log = { log() {}, error() {}, warn: (...a) => logs.push(a.join(' ')) };
++ const t = await boot({ env: { TRADE_LIMITS_MINUTE: '3', TRADE_LIMITS_HOUR: '100' }, limitsNow: () => clock, log });
++ const { alice, bob } = t;
++ const svc = t.network.serviceToken('ai', ['trade.watchlist.read', 'trade.watchlist.create']);
++
++ await check('reads: 3 a minute per person, then 429 rate_limited with Retry-After; another person passes', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/instruments', { as: alice })).status, 200, `read ${i + 1}`);
++ const r = await t.get('/api/v1/instruments', { as: alice });
++ assert.strictEqual(r.status, 429, r.text);
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.strictEqual(r.headers.get('content-type'), 'application/problem+json');
++ const p = r.json();
++ assert.deepStrictEqual([p.code, p.status, p.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(p.detail.includes('trade.read'), p.detail);
++ assert.strictEqual((await t.get('/api/v1/instruments', { as: bob })).status, 200, 'another person still passes');
++ });
++
++ await check('a first-party service naming the person counts against them; reading for itself is not counted', async () => {
++ const named = await t.get('/api/v1/watchlists', { as: svc, headers: { 'x-ov-subject': alice.subject } });
++ assert.deepStrictEqual([named.status, named.json().code], [429, 'rate_limited']);
++ for (let i = 0; i < 6; i++) assert.strictEqual((await t.get('/api/v1/instruments', { as: svc })).status, 200, `service read ${i + 1}`);
++ });
++
++ await check('signed-out reads keep only the per-address limit (many readers share an address)', async () => {
++ for (let i = 0; i < 8; i++) {
++ assert.strictEqual((await t.get('/api/v1/instruments', { headers: { 'X-Forwarded-For': '203.0.113.7' } })).status, 200, `signed-out read ${i + 1}`);
++ assert.strictEqual((await t.get('/api/v1/sources')).status, 200);
++ }
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await t.get('/api/v1/instruments', { as: alice })).status, 200);
++ });
++
++ await check('watchlists: 10 a minute per person, shared by the API and the form; nothing stored past it', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ const count = () => t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM trade_watchlists').get().n;
++ for (let i = 0; i < 10; i++) {
++ const r = await t.get('/api/v1/watchlists', { as: alice, json: { name: `List ${i}` } });
++ assert.strictEqual(r.status, 201, `watchlist ${i + 1}: ${r.text}`);
++ }
++ const before = count();
++ const form = await t.get('/watchlists', { as: alice, form: { _csrf: t.csrf(alice), name: 'One too many' } });
++ assert.deepStrictEqual([form.status, form.json().code, form.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ const viaService = await t.get('/api/v1/watchlists', { as: svc, headers: { 'x-ov-subject': alice.subject }, json: { name: 'Relayed' } });
++ assert.strictEqual(viaService.status, 429, 'a service creating for the person shares their budget');
++ assert.strictEqual(count(), before, 'nothing stored');
++ const other = await t.get('/api/v1/watchlists', { as: bob, json: { name: 'Bob list' } });
++ assert.strictEqual(other.status, 201, `another person still creates: ${other.text}`);
++ });
++
++ await check('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ const body = JSON.stringify({ event: { event_id: `evt_01JABCDEFGHJKMNPQRSTVWXY${String(i).padStart(2, '0')}`, event_type: 'sources.item.created', payload: {} }, seq: i + 1 });
++ const d = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'hook-secret') } });
++ assert.notStrictEqual(d.status, 429, d.text);
++ }
++ });
++
++ await check('refusals are logged (the caller, never a token) and counted in trade_rate_limited_total', async () => {
++ assert.ok(logs.includes(`[Trade] limit trade.read: user:${alice.subject} refused, over 3 per minute`), logs.join('\n'));
++ assert.ok(logs.includes(`[Trade] limit trade.watchlist.create: user:${alice.subject} refused, over 10 per minute`), logs.join('\n'));
++ assert.ok(!logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
++ const m = (await t.get('/metrics')).text;
++ const lines = m.split('\n').filter((l) => l.includes('trade_rate_limited_total')).join('\n');
++ assert.ok(/trade_rate_limited_total\{limit="trade.read",window="minute"\} 2/.test(m), lines);
++ assert.ok(/trade_rate_limited_total\{limit="trade.watchlist.create",window="minute"\} 2/.test(m), lines);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex e2e1ae9..5096212 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -8,6 +8,9 @@
* t.editor, t.alice, t.bob // Network users (the editor is in TRADE_EDITORS)
* t.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)
* t.events(type) // parsed envelopes in event_outbox
++ *
++ * opts: env (overrides), clock, log (Trade's logger; default quiet), limitsNow (the per-actor limiter's
++ * clock; default the wall clock).
*/
const fs = require('fs');
const os = require('os');
@@@ -49,7 +52,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}`;