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}.

OP OpenVibers committed 28 Sept 2026 · 0fbcbb5380 · 9 files, +215 −21

Reads take VIP_LIMITS_MINUTE / VIP_LIMITS_HOUR, 120 and 3000. The entitlement check and policy evaluation are the gates' hot path: Chat badges and the Community and Blog gates ask once per member and resource behind a 30 to 60 second cache, so one service asks about many people and a refusal would lock members out. A service therefore gets 1200 a minute and 30000 an hour there, only enough to stop a runaway loop, and a person asking about themselves the defaults. Checkout asks Billing for a payment intent: a person 10 a minute and 60 an hour, a service starting them for its members 60 and 1200; cancelling, which is handed to Billing, 5 and 30. New plans and perks, publish and archive allow 10 and 100; plan, perk and rule changes and membership preferences 30 and 300. Health, ready, release.json, metrics, /embed and the signed Events deliveries at /internal/events are never limited.

test/actor-limits.test.js on the injected clock: the fourth read in a minute is refused with Retry-After 45 while another person and a service pass, and the next minute reopens; a product's ten entitlement checks pass while a person's fourth is refused; the 11th checkout in a minute is refused before Billing hears of it while another person checks out; health, ready, release.json, metrics and deliveries never answer 429; refusals are logged without a token and counted.

Changes

ddiff --git a/.env.example b/.env.example
iindex 51bfdec..d8f4bd7 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 5bdf66f..034a53a 100644
---- a/README.md
++++ b/README.md
@@@ -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
cannot 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)
ddiff --git a/package-lock.json b/package-lock.json
iindex a01353f..a4f29ba 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 0df08b5..ca85944 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
nnew file mode 100644
iindex 0000000..0248a2b
---- /dev/null
++++ b/server/api/actor-limits.js
@@@ -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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 0656a5b..49798f5 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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);
ddiff --git a/server/app.js b/server/app.js
iindex 020804d..6a3abfd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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' })); }
ddiff --git a/server/config.js b/server/config.js
iindex b825882..a274ca8 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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)),
++ },
};
}
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..b454ca2
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -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();
++})();