openvibers/openvibe.vip Commits
Per-actor rate limits on VIP's /api/v1 (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/api/actor-limits.js makes one limiter per app, counted once api/auth.js resolved the caller and before any work, Billing included: a service as its principal (svc:chat), a person as user:usr_…, anyone else by address. Past a limit a route answers 429 problem+json rate_limited with Retry-After, logs one line and counts vip_rate_limited_total{limit,window}.
Changes
@@@ -63,3 +63,12 @@ VIP_EVENTS_SECRET=# VIP_WIDGET_FRAME_ANCESTORS=*# How long a creator's member count (from Billing) is reused# VIP_MEMBER_COUNT_TTL_MS=300000++++# ── Per-actor limits (server/api/actor-limits.js) ───────++# Reads one caller (a service principal, a person, else an address) may make to /api/v1 per minute++# and per hour; past them 429 rate_limited with Retry-After. Entitlement checks, policy evaluation,++# checkout and catalog changes have their own numbers (README).++# optional: defaults to 120++# VIP_LIMITS_MINUTE=120++# optional: defaults to 3000++# VIP_LIMITS_HOUR=3000@@@ -335,6 +335,16 @@ final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDEallows only its own hashed style. `VIP_WIDGET_FRAME_ANCESTORS` is reduced to source expressions, so itcannot add a CSP directive. The badge SVG is served sandboxed, with every name escaped.`test/embeds.test.js` covers all of this.++- Per-actor limits on `/api/v1` (`server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4),++ counted once the credential is checked and before any work: a service as its principal (`svc:chat`), a++ person as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with++ `Retry-After`, one log line and `vip_rate_limited_total{limit,window}`. Reads take `VIP_LIMITS_MINUTE` /++ `VIP_LIMITS_HOUR` (120 a minute, 3000 an hour). `GET|POST /entitlements/check` and++ `POST /policies/evaluate` give a service 1200 / 30000 (products ask per member and resource behind++ their cache; a refusal would lock members out) and a person the defaults. `POST /checkout`: a person++ 10 / 60, a service 60 / 1200; cancel 5 / 30. New plans and perks, publish and archive 10 / 100; plan,++ perk and rule changes and preferences 30 / 300. Never limited: health, ready, `/release.json`,++ `/metrics`, `/embed` and the signed Events deliveries at `/internal/events` (`test/actor-limits.test.js`).## Acceptance (what the tests prove)@@@ -15,7 +15,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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"},"engines": {@@@ -999,9 +999,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"@@@ -27,7 +27,7 @@"express": "^4.21.2","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.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"},"engines": {nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/**++ * Per-actor rate limits at /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * Counted by who calls, as api/auth.js resolved req.principal: a service by its principal (svc:chat), a++ * person by their subject (user:usr_…), anyone else by address. Past a limit the route answers 429++ * problem+json `rate_limited` with Retry-After before it does any work (before Billing is asked); the++ * refusal is logged once and counted in vip_rate_limited_total{limit,window}. Reads get++ * VIP_LIMITS_MINUTE / VIP_LIMITS_HOUR (120 and 3000); entitlement checks, policy evaluation, checkout++ * and catalog changes set their own numbers in api/v1.js. Counters live in this process: a restart++ * forgets them.++ *++ * Never limited: /api/health, /api/ready, /release.json, /metrics, the public card, badge and widget++ * (/embed, public pages behind nginx), and the signed Events deliveries at /internal/events (Billing's membership changes:++ * Events pushes at its own pace, and a 429 would only make it retry and fall behind).++ */++const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');++++function actor(req) {++ const p = req.principal;++ if (p && p.kind === 'service' && p.sub) return p.sub;++ if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;++ return defaultActor(req);++}++++/**++ * limits(name, own) middleware for one app, plus limits.reads(name, skip): the defaults on GET/HEAD++ * requests, except those `skip(req)` names (routes that set their own).++ */++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {++ const refused = registry++ ? registry.counter({ name: 'vip_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 principal, a subject id or an address, never a token.++ log.warn(`[VIP] 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, skip = () => false) => {++ const limit = limiter(name);++ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !skip(req) ? limit(req, res, next) : next());++ };++ return limiter;++}++++module.exports = { createActorLimits, actor };@@@ -29,9 +29,17 @@ const CAP = {members: 'vip.creator.members.list',};--function v1Router({ domain, apiAuth }) {++function v1Router({ domain, apiAuth, limits }) {const r = express.Router();const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;++++ // Per-actor limits (api/actor-limits.js), after the credential and before any work: reads take the++ // defaults, except the entitlement check, which sets its own below.++ r.use(limits.reads('vip.read', (req) => req.path === '/entitlements/check'));++ const create = limits('vip.catalog.create', { minute: 10, hour: 100 }); // new plans and perks, publish, archive++ const edit = limits('vip.catalog.update', { minute: 30, hour: 300 }); // plan, perk and rule changes, preferences++ /** Services and people meet different numbers on one route: `service` for a principal, `person` otherwise. */++ const byKind = (service, person) => (req, res, next) => (req.principal.kind === 'service' ? service : person)(req, res, next);const granted = apiAuth.granted;const isStaff = apiAuth.isStaff;@@@ -136,7 +144,7 @@ function v1Router({ domain, apiAuth }) {const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);res.json({ plan_id: plan.id, versions: all });}));-- r.post('/plans', wrap((req, res) => {++ r.post('/plans', create, wrap((req, res) => {const b = req.body || {};const c = actingCreator(req, b.creator, CAP.planCreate);const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;@@@ -152,19 +160,19 @@ function v1Router({ domain, apiAuth }) {requireManage(req, creators.byId(plan.creator_id), cap);return plan;};-- r.patch('/plans/:id', wrap((req, res) => {++ r.patch('/plans/:id', edit, wrap((req, res) => {const plan = managedPlan(req, CAP.planUpdate);const b = req.body || {};const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });}));-- r.post('/plans/:id/publish', wrap((req, res) => {++ r.post('/plans/:id/publish', create, wrap((req, res) => {const plan = managedPlan(req, CAP.planUpdate);const out = plans.publish(plan.id, { traceparent: trace(req) });res.json({ plan: presentPlanFull(out.plan), replay: out.replay });}));-- r.post('/plans/:id/archive', wrap((req, res) => {++ r.post('/plans/:id/archive', create, wrap((req, res) => {const plan = managedPlan(req, CAP.planArchive);res.json({ plan: presentPlanFull(plans.archive(plan.id)) });}));@@@ -177,14 +185,14 @@ function v1Router({ domain, apiAuth }) {const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });res.json({ perks: list.map((p) => perks.present(p)) });}));-- r.post('/perks', wrap((req, res) => {++ r.post('/perks', create, wrap((req, res) => {const b = req.body || {};const c = actingCreator(req, b.creator, CAP.perkCreate);const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });res.status(201).json({ perk: perks.present(p) });}));-- r.patch('/perks/:id', wrap((req, res) => {++ r.patch('/perks/:id', edit, wrap((req, res) => {const p = perks.byId(req.params.id);if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);@@@ -207,7 +215,9 @@ function v1Router({ domain, apiAuth }) {if (!u || !ownOrigin || u.origin !== ownOrigin) fail(422, 'vip.invalid_input', `${field} must be a page of ${ownOrigin || 'this site'}`);return u.toString();}-- r.post('/checkout', wrap(async (req, res) => {++ // A checkout asks Billing for a payment intent: a person starts at most 10 a minute and 60 an hour;++ // a service starting them for its members gets one a second (60 and 1200).++ r.post('/checkout', byKind(limits('vip.checkout', { minute: 60, hour: 1200 }), limits('vip.checkout', { minute: 10, hour: 60 })), wrap(async (req, res) => {const b = req.body || {};const member = memberSubject(req, b.subject, CAP.checkout);const out = await checkout.start({@@@ -235,12 +245,13 @@ function v1Router({ domain, apiAuth }) {if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');return c;};-- r.post('/memberships/:creator/cancel', wrap(async (req, res) => {++ // Cancelling is handed to Billing: the member does it once.++ r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');const c = creatorParam(req);res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));}));-- r.put('/memberships/:creator/preferences', wrap((req, res) => {++ r.put('/memberships/:creator/preferences', edit, wrap((req, res) => {if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');const c = creatorParam(req);const b = req.body || {};@@@ -273,8 +284,13 @@ function v1Router({ domain, apiAuth }) {}res.json(out);});-- r.get('/entitlements/check', entitlementCheck);-- r.post('/entitlements/check', entitlementCheck);++ // Products (Chat badges, Community and Blog gates) ask once per member and resource behind a 30 to++ // 60 s cache, so one service asks about many people: 1200 a minute only stops a runaway loop, and a++ // refusal there would lock members out. A person asking about themselves takes the defaults.++ const hot = limits('vip.entitlement.check', { minute: 1200, hour: 30000 });++ const entitlementLimit = byKind(hot, limits('vip.entitlement.check'));++ r.get('/entitlements/check', entitlementLimit, entitlementCheck);++ r.post('/entitlements/check', entitlementLimit, entitlementCheck);// ── Gated-resource policy ────────────────────────────────r.get('/policies', wrap((req, res) => {@@@ -302,7 +318,7 @@ function v1Router({ domain, apiAuth }) {requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);res.json({ rule: policies.present(rule) });}));-- r.post('/policies', wrap((req, res) => {++ r.post('/policies', edit, wrap((req, res) => {const b = req.body || {};const c = actingCreator(req, b.creator, CAP.policySet);if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');@@@ -310,13 +326,14 @@ function v1Router({ domain, apiAuth }) {const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });res.status(201).json({ rule: policies.present(rule) });}));-- r.delete('/policies/:id', wrap((req, res) => {++ r.delete('/policies/:id', edit, wrap((req, res) => {const rule = policies.byId(req.params.id);if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);requireManage(req, creators.byId(rule.creator_id), CAP.policySet);res.json({ rule: policies.present(policies.disable(rule.id)) });}));-- r.post('/policies/evaluate', wrap(async (req, res) => {++ // Evaluation is the gates' hot path too (see the entitlement check).++ r.post('/policies/evaluate', byKind(limits('vip.resource.policy.evaluate', { minute: 1200, hour: 30000 }), limits('vip.resource.policy.evaluate')), wrap(async (req, res) => {const b = req.body || {};const p = req.principal;needAuth(p);@@@ -28,6 +28,7 @@ const { consumerRouter } = require('./events/consumer');const { createDomain } = require('./domain');const { createApiAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { createActorLimits } = require('./api/actor-limits');const { createSessionRoutes } = require('./web/session');const { createWebRoutes } = require('./web/routes');const { createEmbedRoutes } = require('./web/embeds');@@@ -92,7 +93,10 @@ function createApp(opts = {}) {const consumer = consumerRouter({ domain, config, log });app.use('/internal', consumer.router);-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));++ // Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.++ // opts.limitsNow: the limiter's clock (tests).++ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }@@@ -96,6 +96,13 @@ function loadConfig(env = process.env) {frameAncestors: String(env.VIP_WIDGET_FRAME_ANCESTORS || '*').replace(/[;,\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() || '*',memberCountTtlMs: int(env.VIP_MEMBER_COUNT_TTL_MS, 5 * 60 * 1000),},++++ // Per-actor limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): the reads one++ // caller may make per minute and per hour. Checks, checkout and catalog changes set theirs in api/v1.js.++ actorLimits: {++ minute: Math.max(1, int(env.VIP_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.VIP_LIMITS_HOUR, 3000)),++ },};}nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++/**++ * Per-actor rate limits on /api/v1 (server/api/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 checkout past the limit never reaches++ * Billing; a product's entitlement checks have their own, larger budget. Health, ready, release.json,++ * metrics and the Events deliveries are never limited; refusals are logged (no token) and counted.++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++const { actor } = require('../server/api/actor-limits');++++const { test, run } = harness('actor-limits');++++(async () => {++ const t = await boot({ env: { VIP_LIMITS_MINUTE: '3', VIP_LIMITS_HOUR: '100' } });++ // The injected clock, 15 s into a minute: the minute window has 45 s left.++ const minute = (n) => Math.floor(Date.now() / 60_000) * 60_000 + n * 60_000;++ t.clock.t = minute(0) + 15_000;++ const creator = t.network.newUser('lena', { role: 'streamer' });++ const buyer = t.network.newUser('milo');++ const other = t.network.newUser('nora');++++ test('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another caller passes', async () => {++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: buyer })).status, 200);++ const r = await t.call('GET', '/api/v1/creators/network', { user: buyer });++ 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');++ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);++ assert.ok(r.json.detail.includes('vip.read'), r.json.detail);++ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: other })).status, 200, 'another person still passes');++ assert.strictEqual((await t.call('GET', '/api/v1/creators/network')).status, 200, 'a service is its own caller');++ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');++ t.clock.advance(45_000);++ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: buyer })).status, 200, 'the next minute opens the window again');++ });++++ test('entitlement checks: a product asks about many people on its own budget; a person meets the defaults', async () => {++ t.clock.t = minute(2);++ const q = (subject) => `/api/v1/entitlements/check?creator=${creator.subject}&subject=${subject}`;++ for (let i = 0; i < 10; i++) {++ const r = await t.call('GET', q(i % 2 ? buyer.subject : other.subject), { sub: 'svc:chat', cap: ['vip.entitlement.check'] });++ assert.strictEqual(r.status, 200, r.text);++ }++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', q(buyer.subject), { user: buyer })).status, 200);++ const r = await t.call('GET', q(buyer.subject), { user: buyer });++ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);++ assert.ok(r.json.detail.includes('vip.entitlement.check'), r.json.detail);++ });++++ test('checkout: 10 a minute per person, the 11th refused before Billing hears of it', async () => {++ t.clock.t = minute(4);++ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', benefits: ['Badge in chat'], publish: true } })).json.plan;++ assert.ok(plan && plan.purchasable);++ for (let i = 0; i < 10; i++) {++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.strictEqual(r.status, 201, `checkout ${i + 1}: ${r.text}`);++ }++ const intents = t.billing.calls.filter((c) => c.path === '/api/v1/intents').length;++ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });++ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);++ assert.strictEqual(t.billing.calls.filter((c) => c.path === '/api/v1/intents').length, intents, 'Billing never heard of it');++ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } })).status, 201, 'another person still checks out');++ });++++ test('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {++ for (let i = 0; i < 6; i++) {++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);++ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);++ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);++ assert.strictEqual((await fetch(`${t.base}/metrics`)).status, 200);++ assert.notStrictEqual((await t.deliver({ event_id: `evt_limits_${i}`, event_type: 'test.nothing', version: 1 }, { seq: i + 1 })).status, 429);++ }++ });++++ test('refusals are logged (the caller, never a token) and counted in vip_rate_limited_total', async () => {++ assert.ok(t.logs.includes(`[VIP] limit vip.read: user:${buyer.subject} refused, over 3 per minute`), t.logs.join('\n'));++ assert.ok(t.logs.includes(`[VIP] limit vip.checkout: user:${buyer.subject} refused, over 10 per minute`));++ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');++ const m = await (await fetch(`${t.base}/metrics`)).text();++ assert.match(m, /vip_rate_limited_total\{limit="vip.read",window="minute"\} 1/);++ assert.match(m, /vip_rate_limited_total\{limit="vip.checkout",window="minute"\} 1/);++ });++++ test('who is counted', () => {++ assert.strictEqual(actor({ principal: { kind: 'service', sub: 'svc:chat' } }), 'svc:chat');++ assert.strictEqual(actor({ principal: { kind: 'user', subject: 'usr_a' } }), 'user:usr_a');++ assert.strictEqual(actor({ principal: { kind: 'anonymous' }, ip: '203.0.113.9' }), 'ip:203.0.113.9');++ });++++ test('close', async () => { await t.close(); });++++ await run();++})();
@@ -63,3 +63,12 @@ VIP_EVENTS_SECRET=
@@ -63,3 +63,12 @@ VIP_EVENTS_SECRET=
# VIP_WIDGET_FRAME_ANCESTORS=*
# VIP_WIDGET_FRAME_ANCESTORS=*
# How long a creator's member count (from Billing) is reused
# How long a creator's member count (from Billing) is reused
# VIP_MEMBER_COUNT_TTL_MS=300000
# VIP_MEMBER_COUNT_TTL_MS=300000
+
+# ── Per-actor limits (server/api/actor-limits.js) ───────
+# Reads one caller (a service principal, a person, else an address) may make to /api/v1 per minute
+# and per hour; past them 429 rate_limited with Retry-After. Entitlement checks, policy evaluation,
+# checkout and catalog changes have their own numbers (README).
+# optional: defaults to 120
+# VIP_LIMITS_MINUTE=120
+# optional: defaults to 3000
+# VIP_LIMITS_HOUR=3000
@@ -335,6 +335,16 @@ final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDE
@@ -335,6 +335,16 @@ final import), `scripts/subscribe.js`, then open checkout (`VIP_CHECKOUT_PROVIDE
allows only its own hashed style. `VIP_WIDGET_FRAME_ANCESTORS` is reduced to source expressions, so it
allows only its own hashed style. `VIP_WIDGET_FRAME_ANCESTORS` is reduced to source expressions, so it
cannot add a CSP directive. The badge SVG is served sandboxed, with every name escaped.
cannot add a CSP directive. The badge SVG is served sandboxed, with every name escaped.
`test/embeds.test.js` covers all of this.
`test/embeds.test.js` covers all of this.
+- Per-actor limits on `/api/v1` (`server/api/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4),
+ counted once the credential is checked and before any work: a service as its principal (`svc:chat`), a
+ person as `user:usr_…`, anyone else by address. Past a limit: `429` problem+json `rate_limited` with
+ `Retry-After`, one log line and `vip_rate_limited_total{limit,window}`. Reads take `VIP_LIMITS_MINUTE` /
+ `VIP_LIMITS_HOUR` (120 a minute, 3000 an hour). `GET|POST /entitlements/check` and
+ `POST /policies/evaluate` give a service 1200 / 30000 (products ask per member and resource behind
+ their cache; a refusal would lock members out) and a person the defaults. `POST /checkout`: a person
+ 10 / 60, a service 60 / 1200; cancel 5 / 30. New plans and perks, publish and archive 10 / 100; plan,
+ perk and rule changes and preferences 30 / 300. Never limited: health, ready, `/release.json`,
+ `/metrics`, `/embed` and the signed Events deliveries at `/internal/events` (`test/actor-limits.test.js`).
## Acceptance (what the tests prove)
## Acceptance (what the tests prove)
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-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"
},
},
"engines": {
"engines": {
@@ -999,9 +999,9 @@
@@ -999,9 +999,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"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-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"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Per-actor rate limits at /api/v1 (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * Counted by who calls, as api/auth.js resolved req.principal: a service by its principal (svc:chat), a
+ * person by their subject (user:usr_…), anyone else by address. Past a limit the route answers 429
+ * problem+json `rate_limited` with Retry-After before it does any work (before Billing is asked); the
+ * refusal is logged once and counted in vip_rate_limited_total{limit,window}. Reads get
+ * VIP_LIMITS_MINUTE / VIP_LIMITS_HOUR (120 and 3000); entitlement checks, policy evaluation, checkout
+ * and catalog changes set their own numbers in api/v1.js. Counters live in this process: a restart
+ * forgets them.
+ *
+ * Never limited: /api/health, /api/ready, /release.json, /metrics, the public card, badge and widget
+ * (/embed, public pages behind nginx), and the signed Events deliveries at /internal/events (Billing's membership changes:
+ * Events pushes at its own pace, and a 429 would only make it retry and fall behind).
+ */
+const { createActorLimiter, defaultActor } = require('openvibe-sdk/limits');
+
+function actor(req) {
+ const p = req.principal;
+ if (p && p.kind === 'service' && p.sub) return p.sub;
+ if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
+ return defaultActor(req);
+}
+
+/**
+ * limits(name, own) middleware for one app, plus limits.reads(name, skip): the defaults on GET/HEAD
+ * requests, except those `skip(req)` names (routes that set their own).
+ */
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console }) {
+ const refused = registry
+ ? registry.counter({ name: 'vip_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 principal, a subject id or an address, never a token.
+ log.warn(`[VIP] 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, skip = () => false) => {
+ const limit = limiter(name);
+ return (req, res, next) => ((req.method === 'GET' || req.method === 'HEAD') && !skip(req) ? limit(req, res, next) : next());
+ };
+ return limiter;
+}
+
+module.exports = { createActorLimits, actor };
@@ -29,9 +29,17 @@ const CAP = {
@@ -29,9 +29,17 @@ const CAP = {
members: 'vip.creator.members.list',
members: 'vip.creator.members.list',
};
};
-function v1Router({ domain, apiAuth }) {
+function v1Router({ domain, apiAuth, limits }) {
const r = express.Router();
const r = express.Router();
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
+
+ // Per-actor limits (api/actor-limits.js), after the credential and before any work: reads take the
+ // defaults, except the entitlement check, which sets its own below.
+ r.use(limits.reads('vip.read', (req) => req.path === '/entitlements/check'));
+ const create = limits('vip.catalog.create', { minute: 10, hour: 100 }); // new plans and perks, publish, archive
+ const edit = limits('vip.catalog.update', { minute: 30, hour: 300 }); // plan, perk and rule changes, preferences
+ /** Services and people meet different numbers on one route: `service` for a principal, `person` otherwise. */
+ const byKind = (service, person) => (req, res, next) => (req.principal.kind === 'service' ? service : person)(req, res, next);
const granted = apiAuth.granted;
const granted = apiAuth.granted;
const isStaff = apiAuth.isStaff;
const isStaff = apiAuth.isStaff;
@@ -136,7 +144,7 @@ function v1Router({ domain, apiAuth }) {
@@ -136,7 +144,7 @@ function v1Router({ domain, apiAuth }) {
const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);
const all = plans.versions(plan.id).filter((v) => own || v.published_at).map(plans.presentVersion);
res.json({ plan_id: plan.id, versions: all });
res.json({ plan_id: plan.id, versions: all });
}));
}));
- r.post('/plans', wrap((req, res) => {
+ r.post('/plans', create, wrap((req, res) => {
const b = req.body || {};
const b = req.body || {};
const c = actingCreator(req, b.creator, CAP.planCreate);
const c = actingCreator(req, b.creator, CAP.planCreate);
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
@@ -152,19 +160,19 @@ function v1Router({ domain, apiAuth }) {
@@ -152,19 +160,19 @@ function v1Router({ domain, apiAuth }) {
requireManage(req, creators.byId(plan.creator_id), cap);
requireManage(req, creators.byId(plan.creator_id), cap);
return plan;
return plan;
};
};
- r.patch('/plans/:id', wrap((req, res) => {
+ r.patch('/plans/:id', edit, wrap((req, res) => {
const plan = managedPlan(req, CAP.planUpdate);
const plan = managedPlan(req, CAP.planUpdate);
const b = req.body || {};
const b = req.body || {};
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
const out = plans.update(plan.id, { name: b.name, description: b.description, benefits: b.benefits, perks: b.perks, changeNote: b.change_note, actor, traceparent: trace(req) });
res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });
res.json({ plan: presentPlanFull(out.plan), version: plans.presentVersion(out.version), unchanged: out.unchanged });
}));
}));
- r.post('/plans/:id/publish', wrap((req, res) => {
+ r.post('/plans/:id/publish', create, wrap((req, res) => {
const plan = managedPlan(req, CAP.planUpdate);
const plan = managedPlan(req, CAP.planUpdate);
const out = plans.publish(plan.id, { traceparent: trace(req) });
const out = plans.publish(plan.id, { traceparent: trace(req) });
res.json({ plan: presentPlanFull(out.plan), replay: out.replay });
res.json({ plan: presentPlanFull(out.plan), replay: out.replay });
}));
}));
- r.post('/plans/:id/archive', wrap((req, res) => {
+ r.post('/plans/:id/archive', create, wrap((req, res) => {
const plan = managedPlan(req, CAP.planArchive);
const plan = managedPlan(req, CAP.planArchive);
res.json({ plan: presentPlanFull(plans.archive(plan.id)) });
res.json({ plan: presentPlanFull(plans.archive(plan.id)) });
}));
}));
@@ -177,14 +185,14 @@ function v1Router({ domain, apiAuth }) {
@@ -177,14 +185,14 @@ function v1Router({ domain, apiAuth }) {
const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });
const list = perks.list({ creatorId: c.id, includeNetwork: bool(req.query.network, true), includeRetired: own && bool(req.query.retired) });
res.json({ perks: list.map((p) => perks.present(p)) });
res.json({ perks: list.map((p) => perks.present(p)) });
}));
}));
- r.post('/perks', wrap((req, res) => {
+ r.post('/perks', create, wrap((req, res) => {
const b = req.body || {};
const b = req.body || {};
const c = actingCreator(req, b.creator, CAP.perkCreate);
const c = actingCreator(req, b.creator, CAP.perkCreate);
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const actor = req.principal.kind === 'service' ? req.principal.sub : req.principal.subject;
const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });
const p = perks.create({ creatorId: c.id, key: b.key, name: b.name, description: b.description, kind: b.kind, bindings: b.bindings, actor });
res.status(201).json({ perk: perks.present(p) });
res.status(201).json({ perk: perks.present(p) });
}));
}));
- r.patch('/perks/:id', wrap((req, res) => {
+ r.patch('/perks/:id', edit, wrap((req, res) => {
const p = perks.byId(req.params.id);
const p = perks.byId(req.params.id);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);
if (!p) fail(404, 'vip.perk_not_found', `no perk ${req.params.id}`);
requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);
requireManage(req, creators.byId(p.creator_id), CAP.perkUpdate);
@@ -207,7 +215,9 @@ function v1Router({ domain, apiAuth }) {
@@ -207,7 +215,9 @@ function v1Router({ domain, apiAuth }) {
if (!u || !ownOrigin || u.origin !== ownOrigin) fail(422, 'vip.invalid_input', `${field} must be a page of ${ownOrigin || 'this site'}`);
if (!u || !ownOrigin || u.origin !== ownOrigin) fail(422, 'vip.invalid_input', `${field} must be a page of ${ownOrigin || 'this site'}`);
return u.toString();
return u.toString();
}
}
- r.post('/checkout', wrap(async (req, res) => {
+ // A checkout asks Billing for a payment intent: a person starts at most 10 a minute and 60 an hour;
+ // a service starting them for its members gets one a second (60 and 1200).
+ r.post('/checkout', byKind(limits('vip.checkout', { minute: 60, hour: 1200 }), limits('vip.checkout', { minute: 10, hour: 60 })), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const member = memberSubject(req, b.subject, CAP.checkout);
const member = memberSubject(req, b.subject, CAP.checkout);
const out = await checkout.start({
const out = await checkout.start({
@@ -235,12 +245,13 @@ function v1Router({ domain, apiAuth }) {
@@ -235,12 +245,13 @@ function v1Router({ domain, apiAuth }) {
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
if (!c || !c.subject) fail(404, 'vip.creator_not_found', 'no such creator');
return c;
return c;
};
};
- r.post('/memberships/:creator/cancel', wrap(async (req, res) => {
+ // Cancelling is handed to Billing: the member does it once.
+ r.post('/memberships/:creator/cancel', limits('vip.membership.cancel', { minute: 5, hour: 30 }), wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member can cancel their membership');
const c = creatorParam(req);
const c = creatorParam(req);
res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
res.json(await checkout.cancel({ member: req.principal.subject, creatorId: c.id, traceparent: trace(req) }));
}));
}));
- r.put('/memberships/:creator/preferences', wrap((req, res) => {
+ r.put('/memberships/:creator/preferences', edit, wrap((req, res) => {
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
if (req.principal.kind !== 'user') fail(403, 'vip.member_only', 'only the member sets their preferences');
const c = creatorParam(req);
const c = creatorParam(req);
const b = req.body || {};
const b = req.body || {};
@@ -273,8 +284,13 @@ function v1Router({ domain, apiAuth }) {
@@ -273,8 +284,13 @@ function v1Router({ domain, apiAuth }) {
}
}
res.json(out);
res.json(out);
});
});
- r.get('/entitlements/check', entitlementCheck);
- r.post('/entitlements/check', entitlementCheck);
+ // Products (Chat badges, Community and Blog gates) ask once per member and resource behind a 30 to
+ // 60 s cache, so one service asks about many people: 1200 a minute only stops a runaway loop, and a
+ // refusal there would lock members out. A person asking about themselves takes the defaults.
+ const hot = limits('vip.entitlement.check', { minute: 1200, hour: 30000 });
+ const entitlementLimit = byKind(hot, limits('vip.entitlement.check'));
+ r.get('/entitlements/check', entitlementLimit, entitlementCheck);
+ r.post('/entitlements/check', entitlementLimit, entitlementCheck);
// ── Gated-resource policy ────────────────────────────────
// ── Gated-resource policy ────────────────────────────────
r.get('/policies', wrap((req, res) => {
r.get('/policies', wrap((req, res) => {
@@ -302,7 +318,7 @@ function v1Router({ domain, apiAuth }) {
@@ -302,7 +318,7 @@ function v1Router({ domain, apiAuth }) {
requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);
requireManage(req, creators.byId(rule.creator_id), CAP.policyGet);
res.json({ rule: policies.present(rule) });
res.json({ rule: policies.present(rule) });
}));
}));
- r.post('/policies', wrap((req, res) => {
+ r.post('/policies', edit, wrap((req, res) => {
const b = req.body || {};
const b = req.body || {};
const c = actingCreator(req, b.creator, CAP.policySet);
const c = actingCreator(req, b.creator, CAP.policySet);
if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');
if (c.kind !== 'creator') fail(422, 'vip.invalid_input', 'gated resources belong to a creator');
@@ -310,13 +326,14 @@ function v1Router({ domain, apiAuth }) {
@@ -310,13 +326,14 @@ function v1Router({ domain, apiAuth }) {
const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });
const rule = policies.set({ creatorId: c.id, resource: b.resource, requirement: b.requirement, planId: b.plan_id, perkKey: b.perk_key, sensitive: bool(b.sensitive), actor });
res.status(201).json({ rule: policies.present(rule) });
res.status(201).json({ rule: policies.present(rule) });
}));
}));
- r.delete('/policies/:id', wrap((req, res) => {
+ r.delete('/policies/:id', edit, wrap((req, res) => {
const rule = policies.byId(req.params.id);
const rule = policies.byId(req.params.id);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
if (!rule) fail(404, 'vip.rule_not_found', `no rule ${req.params.id}`);
requireManage(req, creators.byId(rule.creator_id), CAP.policySet);
requireManage(req, creators.byId(rule.creator_id), CAP.policySet);
res.json({ rule: policies.present(policies.disable(rule.id)) });
res.json({ rule: policies.present(policies.disable(rule.id)) });
}));
}));
- r.post('/policies/evaluate', wrap(async (req, res) => {
+ // Evaluation is the gates' hot path too (see the entitlement check).
+ r.post('/policies/evaluate', byKind(limits('vip.resource.policy.evaluate', { minute: 1200, hour: 30000 }), limits('vip.resource.policy.evaluate')), wrap(async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const p = req.principal;
const p = req.principal;
needAuth(p);
needAuth(p);
@@ -28,6 +28,7 @@ const { consumerRouter } = require('./events/consumer');
@@ -28,6 +28,7 @@ const { consumerRouter } = require('./events/consumer');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes } = require('./web/session');
const { createSessionRoutes } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
const { createWebRoutes } = require('./web/routes');
const { createEmbedRoutes } = require('./web/embeds');
const { createEmbedRoutes } = require('./web/embeds');
@@ -92,7 +93,10 @@ function createApp(opts = {}) {
@@ -92,7 +93,10 @@ function createApp(opts = {}) {
const consumer = consumerRouter({ domain, config, log });
const consumer = consumerRouter({ domain, config, log });
app.use('/internal', consumer.router);
app.use('/internal', consumer.router);
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth }));
+ // Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
+ // opts.limitsNow: the limiter's clock (tests).
+ const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log });
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
@@ -96,6 +96,13 @@ function loadConfig(env = process.env) {
@@ -96,6 +96,13 @@ function loadConfig(env = process.env) {
frameAncestors: String(env.VIP_WIDGET_FRAME_ANCESTORS || '*').replace(/[;,\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() || '*',
frameAncestors: String(env.VIP_WIDGET_FRAME_ANCESTORS || '*').replace(/[;,\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() || '*',
memberCountTtlMs: int(env.VIP_MEMBER_COUNT_TTL_MS, 5 * 60 * 1000),
memberCountTtlMs: int(env.VIP_MEMBER_COUNT_TTL_MS, 5 * 60 * 1000),
},
},
+
+ // Per-actor limits on /api/v1 (server/api/actor-limits.js, roadmap WS-R task 4): the reads one
+ // caller may make per minute and per hour. Checks, checkout and catalog changes set theirs in api/v1.js.
+ actorLimits: {
+ minute: Math.max(1, int(env.VIP_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.VIP_LIMITS_HOUR, 3000)),
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * Per-actor rate limits on /api/v1 (server/api/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 checkout past the limit never reaches
+ * Billing; a product's entitlement checks have their own, larger budget. Health, ready, release.json,
+ * metrics and the Events deliveries are never limited; refusals are logged (no token) and counted.
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+const { actor } = require('../server/api/actor-limits');
+
+const { test, run } = harness('actor-limits');
+
+(async () => {
+ const t = await boot({ env: { VIP_LIMITS_MINUTE: '3', VIP_LIMITS_HOUR: '100' } });
+ // The injected clock, 15 s into a minute: the minute window has 45 s left.
+ const minute = (n) => Math.floor(Date.now() / 60_000) * 60_000 + n * 60_000;
+ t.clock.t = minute(0) + 15_000;
+ const creator = t.network.newUser('lena', { role: 'streamer' });
+ const buyer = t.network.newUser('milo');
+ const other = t.network.newUser('nora');
+
+ test('a read: 3 a minute per person, then 429 rate_limited with Retry-After; another caller passes', async () => {
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: buyer })).status, 200);
+ const r = await t.call('GET', '/api/v1/creators/network', { user: buyer });
+ 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');
+ assert.deepStrictEqual([r.json.code, r.json.status, r.json.retry_after_seconds], ['rate_limited', 429, 45]);
+ assert.ok(r.json.detail.includes('vip.read'), r.json.detail);
+ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: other })).status, 200, 'another person still passes');
+ assert.strictEqual((await t.call('GET', '/api/v1/creators/network')).status, 200, 'a service is its own caller');
+ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { token: 'not.a.token' })).status, 401, 'a bad token: 401 from auth, never a 429');
+ t.clock.advance(45_000);
+ assert.strictEqual((await t.call('GET', '/api/v1/creators/network', { user: buyer })).status, 200, 'the next minute opens the window again');
+ });
+
+ test('entitlement checks: a product asks about many people on its own budget; a person meets the defaults', async () => {
+ t.clock.t = minute(2);
+ const q = (subject) => `/api/v1/entitlements/check?creator=${creator.subject}&subject=${subject}`;
+ for (let i = 0; i < 10; i++) {
+ const r = await t.call('GET', q(i % 2 ? buyer.subject : other.subject), { sub: 'svc:chat', cap: ['vip.entitlement.check'] });
+ assert.strictEqual(r.status, 200, r.text);
+ }
+ for (let i = 0; i < 3; i++) assert.strictEqual((await t.call('GET', q(buyer.subject), { user: buyer })).status, 200);
+ const r = await t.call('GET', q(buyer.subject), { user: buyer });
+ assert.deepStrictEqual([r.status, r.json.code], [429, 'rate_limited']);
+ assert.ok(r.json.detail.includes('vip.entitlement.check'), r.json.detail);
+ });
+
+ test('checkout: 10 a minute per person, the 11th refused before Billing hears of it', async () => {
+ t.clock.t = minute(4);
+ const plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Front row', benefits: ['Badge in chat'], publish: true } })).json.plan;
+ assert.ok(plan && plan.purchasable);
+ for (let i = 0; i < 10; i++) {
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.strictEqual(r.status, 201, `checkout ${i + 1}: ${r.text}`);
+ }
+ const intents = t.billing.calls.filter((c) => c.path === '/api/v1/intents').length;
+ const r = await t.call('POST', '/api/v1/checkout', { user: buyer, body: { plan_id: plan.id, provider: 'stripe' } });
+ assert.deepStrictEqual([r.status, r.json.code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
+ assert.strictEqual(t.billing.calls.filter((c) => c.path === '/api/v1/intents').length, intents, 'Billing never heard of it');
+ assert.strictEqual((await t.call('POST', '/api/v1/checkout', { user: other, body: { plan_id: plan.id, provider: 'stripe' } })).status, 201, 'another person still checks out');
+ });
+
+ test('health, ready, release.json, metrics and the Events deliveries are never limited', async () => {
+ for (let i = 0; i < 6; i++) {
+ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).status, 200);
+ assert.notStrictEqual((await t.call('GET', '/api/ready', { token: null })).status, 429);
+ assert.strictEqual((await t.call('GET', '/release.json', { token: null })).status, 200);
+ assert.strictEqual((await fetch(`${t.base}/metrics`)).status, 200);
+ assert.notStrictEqual((await t.deliver({ event_id: `evt_limits_${i}`, event_type: 'test.nothing', version: 1 }, { seq: i + 1 })).status, 429);
+ }
+ });
+
+ test('refusals are logged (the caller, never a token) and counted in vip_rate_limited_total', async () => {
+ assert.ok(t.logs.includes(`[VIP] limit vip.read: user:${buyer.subject} refused, over 3 per minute`), t.logs.join('\n'));
+ assert.ok(t.logs.includes(`[VIP] limit vip.checkout: user:${buyer.subject} refused, over 10 per minute`));
+ assert.ok(!t.logs.some((l) => /Bearer|eyJ/.test(l)), 'no token in the log');
+ const m = await (await fetch(`${t.base}/metrics`)).text();
+ assert.match(m, /vip_rate_limited_total\{limit="vip.read",window="minute"\} 1/);
+ assert.match(m, /vip_rate_limited_total\{limit="vip.checkout",window="minute"\} 1/);
+ });
+
+ test('who is counted', () => {
+ assert.strictEqual(actor({ principal: { kind: 'service', sub: 'svc:chat' } }), 'svc:chat');
+ assert.strictEqual(actor({ principal: { kind: 'user', subject: 'usr_a' } }), 'user:usr_a');
+ assert.strictEqual(actor({ principal: { kind: 'anonymous' }, ip: '203.0.113.9' }), 'ip:203.0.113.9');
+ });
+
+ test('close', async () => { await t.close(); });
+
+ await run();
+})();