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}.
Changes
@@@ -12,6 +12,12 @@ NODE_ENV=developmentBASE_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@@@ -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 replaywindow; 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; Searchdocuments 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@@@ -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"@@@ -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": {@@@ -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({@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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;@@@ -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);@@@ -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' });nnew file mode 100644@@@ -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();++})();@@@ -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}`;
@@ -12,6 +12,12 @@ NODE_ENV=development
@@ -12,6 +12,12 @@ NODE_ENV=development
BASE_URL=http://localhost:4860
BASE_URL=http://localhost:4860
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
# Proxy hops that set X-Forwarded-For (Cloudflare → nginx → Node = 2)
TRUST_PROXY=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)
# SQLite database (production unit: /var/lib/openvibe-trade/trade.db)
TRADE_DB_PATH=./data/trade.db
TRADE_DB_PATH=./data/trade.db
@@ -281,11 +281,41 @@ registry, atomically. A placeholder never counts as an implemented service.
@@ -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
- **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).
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
- **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);
- **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
there is no market data source yet; Network does not consume `trade.alert.triggered` yet; Search
documents exist only for instruments with reviewed context.
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
## Development
```bash
```bash
@@ -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.49.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-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.49.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-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": {
@@ -46,6 +46,7 @@ const { createPrivate } = require('./http/private');
@@ -46,6 +46,7 @@ const { createPrivate } = require('./http/private');
const { createEditor } = require('./http/editor');
const { createEditor } = require('./http/editor');
const { createDiscovery } = require('./http/discovery');
const { createDiscovery } = require('./http/discovery');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createActorLimits } = require('./http/actor-limits');
const { define } = require('./http/routes');
const { define } = require('./http/routes');
const { createTradeReadiness } = require('./observability');
const { createTradeReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
@@ -54,7 +55,8 @@ const { assetVersion } = require('./render/layout');
@@ -54,7 +55,8 @@ 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; default the wall clock) */
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;
@@ -89,6 +91,9 @@ function createApp(opts = {}) {
@@ -89,6 +91,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'trade', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'trade', 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) 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(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 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',
dbPath: env.TRADE_DB_PATH || './data/trade.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 };
@@ -68,6 +68,11 @@ function createApi(ctx) {
@@ -68,6 +68,11 @@ function createApi(ctx) {
router.use(cors(config.apiCorsOrigins));
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use(viewers.middleware());
router.use(function privateApi(req, res, next) { privateNoStore(res); res.set('X-Robots-Tag', 'noindex'); next(); });
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 });
const tp = (req) => ({ traceparent: req.ov && req.ov.traceparent });
@@ -113,7 +118,7 @@ function createApi(ctx) {
@@ -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);
mustEditor(req);
const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
const by = req.viewer.kind === 'service' ? req.viewer.service : req.viewer.subject;
const i = instruments.create(req.body || {}, by);
const i = instruments.create(req.body || {}, by);
@@ -126,14 +131,14 @@ function createApi(ctx) {
@@ -126,14 +131,14 @@ function createApi(ctx) {
return data;
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);
mustEditor(req);
const i = instruments.update(mustInstrument(req), req.body || {});
const i = instruments.update(mustInstrument(req), req.body || {});
store.tx(() => indexing.refresh(i, tp(req)));
store.tx(() => indexing.refresh(i, tp(req)));
return { instrument: reading.instrumentDto(i) };
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);
mustEditor(req);
const i = mustInstrument(req);
const i = mustInstrument(req);
const b = req.body || {};
const b = req.body || {};
@@ -154,7 +159,7 @@ function createApi(ctx) {
@@ -154,7 +159,7 @@ function createApi(ctx) {
// ── Observations (first-party feeds) ────────────────────
// ── 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');
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 b = req.body || {};
const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);
const instrument = b.instrument_id ? instruments.get(String(b.instrument_id)) : instruments.bySymbol(b.symbol);
@@ -179,14 +184,14 @@ function createApi(ctx) {
@@ -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, '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 i = mustInstrument(req);
const out = context.propose(req.viewer, i, req.body || {});
const out = context.propose(req.viewer, i, req.body || {});
ctx.outbox.kick();
ctx.outbox.kick();
return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };
return { revision: context.view(ctx.instruments.get(i.id), out.revision), published: out.published };
}, 201));
}, 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 i = mustInstrument(req);
const b = req.body || {};
const b = req.body || {};
const out = context.review(req.viewer, i, parseInt(req.params.n, 10), { decision: b.decision, note: b.note });
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) {
@@ -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, '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, '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);
const w = watchlists.mustOwn(owner(req), req.params.id);
return { watchlist: watchlists.dto(watchlists.rename(w, req.body || {})) };
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 w = watchlists.mustOwn(owner(req), req.params.id);
const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);
const added = watchlists.add(w, instruments.bySymbol(req.params.symbol), (req.body || {}).note);
return { added, watchlist: watchlists.dto(w) };
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);
const w = watchlists.mustOwn(owner(req), req.params.id);
return { removed: watchlists.drop(w, instruments.bySymbol(req.params.symbol)), watchlist: watchlists.dto(w) };
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) ────────────────────────────────────
// ── Alerts (private) ────────────────────────────────────
@@ -230,13 +235,13 @@ function createApi(ctx) {
@@ -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, '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 b = req.body || {};
const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);
const rule = alerts.create(owner(req), instruments.bySymbol(b.symbol), b);
return { rule: alerts.dto(rule) };
return { rule: alerts.dto(rule) };
}, 201));
}, 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 }); });
router.use(function apiNotFound(req, res) { contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }); });
return router;
return router;
@@ -32,6 +32,9 @@ function createEditor(ctx) {
@@ -32,6 +32,9 @@ function createEditor(ctx) {
next();
next();
}
}
const form = [...common.signedInForm, requireEditor];
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) {
function mustInstrument(req) {
const i = instruments.bySymbol(req.params.symbol);
const i = instruments.bySymbol(req.params.symbol);
@@ -53,7 +56,7 @@ function createEditor(ctx) {
@@ -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 }) });
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 {
try {
const i = instruments.create(req.body, req.viewer.subject);
const i = instruments.create(req.body, req.viewer.subject);
ctx.store.tx(() => indexing.refresh(i));
ctx.store.tx(() => indexing.refresh(i));
@@ -75,7 +78,7 @@ function createEditor(ctx) {
@@ -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 {
try {
const i = mustInstrument(req);
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 });
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) {
@@ -84,7 +87,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} 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 {
try {
const i = mustInstrument(req);
const i = mustInstrument(req);
instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
instruments.addAlias(i, String(req.body.kind || ''), req.body.value, req.viewer.subject);
@@ -92,7 +95,7 @@ function createEditor(ctx) {
@@ -92,7 +95,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} 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 {
try {
const i = mustInstrument(req);
const i = mustInstrument(req);
const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
const raw = req.body.cite == null ? [] : Array.isArray(req.body.cite) ? req.body.cite : [req.body.cite];
@@ -105,7 +108,7 @@ function createEditor(ctx) {
@@ -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 {
try {
const i = mustInstrument(req);
const i = mustInstrument(req);
const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
const decisionValue = req.body.decision === 'approved' ? 'approved' : 'rejected';
@@ -114,7 +117,7 @@ function createEditor(ctx) {
@@ -114,7 +117,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} 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 {
try {
const i = mustInstrument(req);
const i = mustInstrument(req);
context.publish(i, parseInt(req.params.n, 10));
context.publish(i, parseInt(req.params.n, 10));
@@ -122,7 +125,7 @@ function createEditor(ctx) {
@@ -122,7 +125,7 @@ function createEditor(ctx) {
} catch (err) { fail(req, res, `/editor/i/${encodeURIComponent(req.params.symbol)}`, err); }
} 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 {
try {
const i = mustInstrument(req);
const i = mustInstrument(req);
context.retract(req.viewer, i);
context.retract(req.viewer, i);
@@ -23,6 +23,9 @@ const { asApiError } = require('./errors');
@@ -23,6 +23,9 @@ const { asApiError } = require('./errors');
function createPrivate(ctx) {
function createPrivate(ctx) {
const { instruments, watchlists, alerts, common } = ctx;
const { instruments, watchlists, alerts, common } = ctx;
const router = express.Router();
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 } });
const privateDecision = seo.evaluate({ state: 'published', visibility: 'private', canonicalUrl: null }, { policy: { minWords: 0 } });
function back(req, res, fallback, err) {
function back(req, res, fallback, err) {
@@ -50,28 +53,28 @@ function createPrivate(ctx) {
@@ -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 {
try {
watchlists.create(req.viewer.subject, { name: req.body.name });
watchlists.create(req.viewer.subject, { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_created' });
common.after(res, '/watchlists', { n: 'watchlist_created' });
} catch (err) { back(req, res, '/watchlists', err); }
} 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 {
try {
watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
watchlists.rename(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id), { name: req.body.name });
common.after(res, '/watchlists', { n: 'watchlist_renamed' });
common.after(res, '/watchlists', { n: 'watchlist_renamed' });
} catch (err) { back(req, res, '/watchlists', err); }
} 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 {
try {
watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
watchlists.remove(watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id));
common.after(res, '/watchlists', { n: 'watchlist_deleted' });
common.after(res, '/watchlists', { n: 'watchlist_deleted' });
} catch (err) { back(req, res, '/watchlists', err); }
} 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 {
try {
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
const instrument = instrumentFrom(req.body);
const instrument = instrumentFrom(req.body);
@@ -81,7 +84,7 @@ function createPrivate(ctx) {
@@ -81,7 +84,7 @@ function createPrivate(ctx) {
} catch (err) { back(req, res, '/watchlists', err); }
} 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 {
try {
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
const w = watchlists.mustOwn(req.viewer.subject, req.body.watchlist_id);
watchlists.drop(w, instruments.bySymbol(req.body.symbol));
watchlists.drop(w, instruments.bySymbol(req.body.symbol));
@@ -89,7 +92,7 @@ function createPrivate(ctx) {
@@ -89,7 +92,7 @@ function createPrivate(ctx) {
} catch (err) { back(req, res, '/watchlists', err); }
} 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);
const instrument = instruments.bySymbol(req.body.symbol);
try {
try {
alerts.create(req.viewer.subject, instrument, {
alerts.create(req.viewer.subject, instrument, {
@@ -104,7 +107,7 @@ function createPrivate(ctx) {
@@ -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 {
try {
alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
alerts.remove(req.viewer.subject, String(req.body.rule_id || ''));
common.after(res, '/watchlists', { n: 'alert_deleted' });
common.after(res, '/watchlists', { n: 'alert_deleted' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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();
+})();
@@ -8,6 +8,9 @@
@@ -8,6 +8,9 @@
* t.editor, t.alice, t.bob // Network users (the editor is in TRADE_EDITORS)
* 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.instrument({ symbol, name, cik }) // create an instrument through the domain (seed for tests)
* t.events(type) // parsed envelopes in event_outbox
* 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 fs = require('fs');
const os = require('os');
const os = require('os');
@@ -49,7 +52,7 @@ async function boot(opts = {}) {
@@ -49,7 +52,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}`;