openvibers/openvibe.codes Commits

Per-actor rate limits at Codes' capability boundaries (roadmap WS-R task 4, openvibe-sdk v0.12.0 limits). server/http/actor-limits.js makes one limiter per app. It covers /api/v1, the signed-in portal (/projects), release actions, the developer tools' forms and the staff trust form. An app token counts as its app (app:app_…, the principal requireCapability verified), a signed-in person as user:usr_…, anyone else by address. Codes hosts no git remotes, so there are no clone or push routes to leave out. Past a limit a route answers 429 problem+json rate_limited with Retry-After before any work (before a form or upload is read and before Network is asked), logs one [Limits] line and counts codes_rate_limited_total{limit,window}.

OP OpenVibers committed 28 Sept 2026 · d78de7052c · 14 files, +303 −43

API reads and portal pages, each of which asks Network with the person's token, take CODES_LIMITS_MINUTE / CODES_LIMITS_HOUR, 120 and 3000. Writes and exports have their own numbers:
- Project create: 5 a minute, 30 an hour. Members, roles, archive and delete: 20 and 200.
- Project export, JSON and the full archive, which reads every object and event a project holds: 3 and 20.
- App create: 10 and 60. Redirect URIs, grants and app revoke: 30 and 300. Credential rotate and revoke, which mint or kill a secret: 10 and 60.
- Playground runs: 10 and 120, above the 60 runs an hour per person, which keeps deciding.
- Release create, where the editor's "validate only" counts too: 20 and 200. Release publish, deprecate and revoke, in the portal or with an app token: 20 and 200.
- Manifest validation (form and API) and the webhook verify and sample tools: 30 and 600 each.
- Staff trust tier: 30 and 300.

The per-address limits (pages 300 a minute, portal writes 60, tools 30, API writes 60, sign-in) stay. Health, ready, release.json, metrics, sign-in, and the public pages and docs are never limited per actor. createApp takes limitsNow (the limiter's clock) and actorLimits: false, which counts nobody. The test helper uses it, as it already raises the per-address limit, because several tests run bursts of exports, playground runs and release drafts as one person. test/docs.test.js now expects SDK 0.12.0.

test/actor-limits.test.js runs on a fixed limiter clock. The fourth portal read in a minute is refused with Retry-After 45 without Network being asked, while another person passes. An app token counts as its app while another app passes, and signed-out callers count by forwarded address. The next minute reopens. The sixth project in a minute is refused before Network hears of it while another person still creates one. Health, ready, release.json and metrics never answer 429. Refusals are counted and logged without a token.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3bffe74..e204f2c 100644
---- a/.env.example
++++ b/.env.example
@@@ -6,6 +6,11 @@ HOST=127.0.0.1
BASE_URL=https://openvibe.codes
TRUST_PROXY=2
CODES_DB_PATH=/var/lib/openvibe-codes/codes.db
++# Per-actor limits (server/http/actor-limits.js): reads one caller (an app, a person, else an address)
++# may make to the API and the portal per minute and per hour; past them 429 rate_limited with
++# Retry-After. Writes have their own numbers (README). The per-address limits stay.
++#CODES_LIMITS_MINUTE=120
++#CODES_LIMITS_HOUR=3000
# OpenVibe.Network: SSO (OAuth client `codes`, PKCE S256), JWKS, the /api/v1/projects API, the registry.
OV_NETWORK_URL=https://openvibe.network
ddiff --git a/README.md b/README.md
iindex 4fee3a0..fe91832 100644
---- a/README.md
++++ b/README.md
@@@ -64,7 +64,37 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
## Configuration
--See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `CODES_FORM_SECRET`, `BASE_URL`, `CODES_DB_PATH`. Optional: `EVENTS_URL` (relay), `CODES_STAFF_SUBJECTS`, `CODES_PLAYGROUND_*`, `CODES_REGISTRY_TTL_MS`.
++See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET`, `CODES_FORM_SECRET`, `BASE_URL`, `CODES_DB_PATH`. Optional: `EVENTS_URL` (relay), `CODES_STAFF_SUBJECTS`, `CODES_PLAYGROUND_*`, `CODES_REGISTRY_TTL_MS`, `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR`.
++
++### Per-actor limits
++
++`/api/v1`, the portal (`/projects`), release actions, the tools' forms and the staff trust form also
++limit who calls them, once the caller is known and before any work (before a form or upload is read,
++before Network is asked): `server/http/actor-limits.js`, openvibe-sdk/limits, roadmap WS-R task 4. An
++app token counts as its app (`app:app_…`), a signed-in person as `user:usr_…`, anyone else by address.
++Past a limit: `429` problem+json `rate_limited` with `Retry-After`, one `[Limits]` log line and
++`codes_rate_limited_total{limit,window}`. The per-address limits (pages 300 a minute, portal writes 60,
++tools 30, API writes 60, sign-in) and the playground's runs an hour stay. Codes hosts no git remotes, so
++there are no clone or push routes to leave out.
++
++| Routes | Per caller, a minute / an hour |
++|---|---|
++| API reads; portal pages (each asks Network with the person's token) | `CODES_LIMITS_MINUTE` / `CODES_LIMITS_HOUR` (120 / 3000) |
++| Project create | 5 / 30 |
++| Members, roles, archive, delete | 20 / 200 |
++| Project export (JSON and the full archive) | 3 / 20 |
++| App create | 10 / 60 |
++| Redirect URIs, grants, app revoke | 30 / 300 |
++| Credential rotate and revoke | 10 / 60 |
++| Playground runs (above the 60 an hour per person) | 10 / 120 |
++| Release create and "validate only" (portal and API) | 20 / 200 |
++| Release publish, deprecate, revoke (portal and API) | 20 / 200 |
++| Manifest validate (form and API); webhook verify and sample | 30 / 600 each |
++| Staff trust tier | 30 / 300 |
++
++Never limited per actor: `/api/health`, `/api/ready`, `/release.json`, `/metrics`, sign-in, and the
++public pages and docs. `test/actor-limits.test.js`; the other tests boot with the per-actor limits off,
++as they raise the per-address limit.
## Deploy (for the lead)
ddiff --git a/package-lock.json b/package-lock.json
iindex 7c9b998..733d11e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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": {
@@@ -1039,9 +1039,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 b2df85c..7ab1078 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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/app.js b/server/app.js
iindex 5e19a8b..304e3a9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -38,13 +38,17 @@ const { createPageRoutes } = require('./http/pages');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
const { createApi } = require('./http/api');
const { createCodesReadiness } = require('./observability');
++const { createActorLimits } = require('./http/actor-limits');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--/** opts: config, store | dbPath, now (clock), fetchImpl, log */
++/**
++ * opts: config, store | dbPath, now (clock), fetchImpl, log, limitsNow (the per-actor limiter's clock,
++ * tests), actorLimits (false: count nobody, tests only)
++ */
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const log = opts.log || console;
@@@ -72,6 +76,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'codes', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
++ // Per-actor limits (http/actor-limits.js) at /api/v1, the portal, release actions and the tools'
++ // forms, counted once the caller is known; the per-address limits below stay.
++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false });
app.use(contracts.http.middleware());
app.use(helmet({
ddiff --git a/server/config.js b/server/config.js
iindex db46f8c..d6ef131 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -33,6 +33,12 @@ function load(env = process.env) {
isProduction,
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 reads one caller (an
++ // app, a person, else an address) may make per minute and per hour. Writes set tighter numbers there.
++ limits: {
++ minute: Math.max(1, int(env.CODES_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.CODES_LIMITS_HOUR, 3000)),
++ },
dbPath: env.CODES_DB_PATH || './data/codes.db',
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
nnew file mode 100644
iindex 0000000..9ee2626
---- /dev/null
++++ b/server/http/actor-limits.js
@@@ -0,0 +1,96 @@
++'use strict';
++
++/**
++ * Per-actor rate limits at Codes' capability boundaries (roadmap WS-R task 4; openvibe-sdk/limits):
++ * /api/v1, the signed-in portal (/projects), release actions, the developer tools' forms and the
++ * staff trust form.
++ *
++ * The per-address limits in app.js and the routers (pages 300 a minute, portal writes 60, the tools and
++ * API writes 30 and 60, sign-in) and the playground's runs an hour per person stay. These count
++ * requests by who makes them:
++ *
++ * an app its principal, app:app_… (the token requireCapability verified on /api/v1)
++ * a person user:usr_… (the signed-in session sso.middleware verified)
++ * anyone else ip:<address>
++ *
++ * Codes hosts no git remotes: there are no clone or push routes to leave out. Past a limit the route
++ * answers 429 problem+json `rate_limited` with Retry-After before it does any work (before the form or
++ * upload is read, before Network is asked); the refusal is logged once and counted in
++ * codes_rate_limited_total{limit,window}. Reads take CODES_LIMITS_MINUTE / CODES_LIMITS_HOUR (120 and
++ * 3000); every write has its own number below. Counters live in this process: a restart forgets them.
++ *
++ * Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and
++ * docs (the per-address limit bounds them).
++ */
++const { createActorLimiter } = require('openvibe-sdk/limits');
++
++function actor(req) {
++ const p = req.principal;
++ if (p && !p.legacy && typeof p.sub === 'string' && p.sub) return p.sub;
++ const v = req.viewer;
++ if (v && v.kind === 'user' && v.subject) return `user:${v.subject}`;
++ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
++}
++
++/** The writes and the expensive reads, each with its numbers per caller (a minute, an hour). */
++const BUDGETS = {
++ // A project is a Network record with its own members and environments: people start a few.
++ 'codes.project.create': { minute: 5, hour: 30 },
++ // Members, roles, archive and delete: an owner changes a few at a time.
++ 'codes.project.manage': { minute: 20, hour: 200 },
++ // The full archive reads every object and event a project holds (one at a time per project).
++ 'codes.project.export': { minute: 3, hour: 20 },
++ // An app is a Network client with credentials: a developer registers a few.
++ 'codes.app.create': { minute: 10, hour: 60 },
++ // Redirect URIs, grants and revoking an app: a developer saves a form now and then.
++ 'codes.app.manage': { minute: 30, hour: 300 },
++ // Rotating or revoking a credential mints or kills a secret: a few at a time.
++ 'codes.app.credential': { minute: 10, hour: 60 },
++ // A playground run calls Events or Media with the app's own credential: above the runs an hour
++ // per person (CODES_PLAYGROUND_RUNS_PER_HOUR, 60), which keeps deciding; refusals count.
++ 'codes.playground.run': { minute: 10, hour: 120 },
++ // A release draft (a validated manifest; the editor's "validate only" counts too, so a developer
++ // fixing a manifest has room): 20 a minute, 200 an hour.
++ 'codes.release.create': { minute: 20, hour: 200 },
++ // Publishing, deprecating and revoking a release announce it to the network.
++ 'codes.release.manage': { minute: 20, hour: 200 },
++ // Validating a manifest or checking a webhook signature: a developer's pace, not a crawler's.
++ 'codes.manifest.validate': { minute: 30, hour: 600 },
++ 'codes.webhook.tool': { minute: 30, hour: 600 },
++ // Staff setting an app's trust tier.
++ 'codes.trust.set': { minute: 30, hour: 300 },
++};
++
++/**
++ * limits(name, own) middleware for one app, plus limits.reads(name) (the defaults on GET/HEAD) and
++ * limits.budget(name) (one of BUDGETS). enabled=false (tests only, as they raise the per-address
++ * limit) counts nobody.
++ */
++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
++ const refused = registry
++ ? registry.counter({ name: 'codes_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.limits.minute, hour: config.limits.hour },
++ actor: enabled ? actor : () => null,
++ now,
++ onLimited(e) {
++ // The actor is an app principal, a subject id or an address, never a token.
++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? 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, BUDGETS };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a52b670..7e9f9f4 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -32,18 +32,22 @@ const MANAGE = 'codes.release.manage';
const READ = 'codes.release.read';
function createApi(ctx) {
-- const { config, docs, releases, trust, keys } = ctx;
++ const { config, docs, releases, trust, keys, actorLimits } = ctx;
const r = asyncRouter();
++ // Per-actor limits (http/actor-limits.js): reads take the defaults once the token (if any) is checked;
++ // writes name their budget after the guard and before the body is read.
++ const reads = actorLimits.reads('codes.read');
++ const B = (name) => actorLimits.budget(name);
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const json = express.json({ limit: '96kb' });
const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
-- r.get('/docs/versions', (req, res) => {
++ r.get('/docs/versions', reads, (req, res) => {
res.json({ contracts: docs.contractsVersion, contracts_tag: docs.contractsTag, sdk: docs.sdkVersion, sdk_tag: docs.sdkTag, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });
});
-- r.post('/manifests/validate', limiter, json, (req, res) => {
++ r.post('/manifests/validate', limiter, B('codes.manifest.validate'), json, (req, res) => {
const b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : (b.kind === 'app' ? 'app' : null);
if (!kind) return problem(req, res, 422, 'manifest.kind', 'kind is app or mod');
@@@ -62,17 +66,17 @@ function createApi(ctx) {
const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
: problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];
-- r.get('/apps/:app/releases', ...readAccess, (req, res) => {
++ r.get('/apps/:app/releases', ...readAccess, reads, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });
});
-- r.get('/apps/:app/trust', ...readAccess, (req, res) => {
++ r.get('/apps/:app/trust', ...readAccess, reads, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
});
-- r.get('/releases/:id', ...readAccess, (req, res) => {
++ r.get('/releases/:id', ...readAccess, reads, (req, res) => {
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
res.set('Cache-Control', 'public, max-age=60');
@@@ -104,7 +108,7 @@ function createApi(ctx) {
return problem(req, res, 500, 'internal.error', 'Internal error');
};
-- r.post('/apps/:app/releases', limiter, ...manageAccess, json, (req, res) => {
++ r.post('/apps/:app/releases', limiter, ...manageAccess, B('codes.release.create'), json, (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
const p = appPrincipal(req, res, req.params.app);
if (!p) return;
@@@ -120,7 +124,7 @@ function createApi(ctx) {
});
for (const action of ['publish', 'deprecate', 'revoke']) {
-- r.post(`/releases/:id/${action}`, limiter, ...manageAccess, json, (req, res) => {
++ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('codes.release.manage'), json, (req, res) => {
const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
const p = appPrincipal(req, res, rel.app_id);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 852af2a..2e33909 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -253,7 +253,8 @@ ${signedIn ? releaseActions(req, rel) : ''}
<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], trust.listSet().map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,
});
});
-- r.post('/staff/trust', form, (req, res) => {
++ // Per-actor limit (http/actor-limits.js) before the form is read.
++ r.post('/staff/trust', ctx.actorLimits.budget('codes.trust.set'), form, (req, res) => {
if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
try {
trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 6e3d6fc..741c2a4 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -41,8 +41,9 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
-- const { config, docs, network, sso, releases, trust, playground, archiver, limits, log } = ctx;
++ const { config, docs, network, sso, releases, trust, playground, archiver, limits, actorLimits, log } = ctx;
const r = asyncRouter();
++ const B = (name) => actorLimits.budget(name);
const form = express.urlencoded({ extended: false, limit: '96kb' });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -79,6 +80,9 @@ ${back ? html`<p><a href="${back}">Back</a></p>` : ''}`,
if (!req.viewer.subject) return page(req, res, { title: 'Account', body: html`<h1>Your account has no subject id yet</h1><p>Sign out and in again; Network assigns one on sign-in.</p>` }, 409);
next();
});
++ // Per-actor limits (http/actor-limits.js), for the signed-in person: every page asks Network with their
++ // token, so reads take the defaults; each write names its budget before its form is read.
++ r.use(actorLimits.reads('codes.portal.read'));
// Every state change: same-origin and a valid form token.
const guard = (req, res, next) => {
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
@@@ -111,7 +115,7 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
}, status);
}
r.get('/', (req, res) => projectsPage(req, res));
-- r.post('/', form, guard, async (req, res) => {
++ r.post('/', B('codes.project.create'), form, guard, async (req, res) => {
const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));
if (!got.ok) return projectsPage(req, res, { problem: got.problem, status: got.problem.status });
res.redirect(303, `/projects/${got.data.id}`);
@@@ -213,30 +217,30 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
res.redirect(303, `${target}?done=${encodeURIComponent(done)}`);
};
-- r.post('/:project/members', idParams, form, guard, async (req, res) => {
++ r.post('/:project/members', idParams, B('codes.project.manage'), form, guard, async (req, res) => {
const username = String(req.body.username || '').trim().replace(/^@/, '');
const got = await net(req, res, (t) => network.projects.addMember(t, req.params.project, { username, role: String(req.body.role || 'viewer') }));
after(req, res, got, back(req), `Added ${username}`);
});
-- r.post('/:project/members/:subject/role', idParams, form, guard, async (req, res) => {
++ r.post('/:project/members/:subject/role', idParams, B('codes.project.manage'), form, guard, async (req, res) => {
if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.invalid', detail: 'not a member subject' }, { back: back(req) });
const got = await net(req, res, (t) => network.projects.updateMember(t, req.params.project, req.params.subject, { role: String(req.body.role || '') }));
after(req, res, got, back(req), 'Role changed');
});
-- r.post('/:project/members/:subject/remove', idParams, form, guard, async (req, res) => {
++ r.post('/:project/members/:subject/remove', idParams, B('codes.project.manage'), form, guard, async (req, res) => {
if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.invalid', detail: 'not a member subject' }, { back: back(req) });
const self = req.params.subject === req.viewer.subject;
const got = await net(req, res, (t) => network.projects.removeMember(t, req.params.project, req.params.subject));
if (got.ok && self) return res.redirect(303, '/projects');
after(req, res, got, back(req), 'Member removed');
});
-- r.post('/:project/archive', idParams, form, guard, async (req, res) => {
++ r.post('/:project/archive', idParams, B('codes.project.manage'), form, guard, async (req, res) => {
if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.confirm', detail: 'tick the box to confirm' }, { back: back(req) });
const got = await net(req, res, (t) => network.projects.archive(t, req.params.project));
after(req, res, got, back(req), 'Project archived');
});
-- r.get('/:project/export', idParams, async (req, res) => {
++ r.get('/:project/export', idParams, B('codes.project.export'), async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
const got = await net(req, res, (t) => buildExport({
@@@ -252,7 +256,7 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
// The full archive (owner/admin; Network checks the role again when it mints the export tokens).
// One at a time per project: it reads every object and event the project holds.
const exporting = new Set();
-- r.post('/:project/export/archive', idParams, form, guard, async (req, res) => {
++ r.post('/:project/export/archive', idParams, B('codes.project.export'), form, guard, async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
if (!atLeast(project.role, 'admin')) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner or an admin can download the full archive' }, { title: 'Export', back: back(req) });
@@@ -286,7 +290,7 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
}
});
-- r.post('/:project/delete', idParams, form, guard, async (req, res) => {
++ r.post('/:project/delete', idParams, B('codes.project.manage'), form, guard, async (req, res) => {
const project = await loadProject(req, res);
if (!project) return;
if (project.role !== 'owner' && !req.viewer.staff) return problemPage(req, res, { status: 403, code: 'project.forbidden', detail: 'only the project owner can delete it' }, { back: back(req) });
@@@ -363,7 +367,7 @@ const client = sdk.createClient({
});
}
-- r.post('/:project/apps', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps', idParams, B('codes.app.create'), form, guard, async (req, res) => {
const body = {
name: String(req.body.name || ''), environment: String(req.body.environment || 'sandbox'),
type: String(req.body.type || 'confidential'), redirect_uris: redirectList(req.body.redirect_uris),
@@@ -495,16 +499,16 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
}
const appBase = (req) => `/projects/${req.params.project}/apps/${req.params.app}`;
-- r.post('/:project/apps/:app/redirects', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/redirects', idParams, B('codes.app.manage'), form, guard, async (req, res) => {
const got = await net(req, res, (t) => network.projects.updateApp(t, req.params.project, req.params.app, { redirect_uris: redirectList(req.body.redirect_uris) }));
after(req, res, got, appBase(req), 'Redirect URIs saved');
});
-- r.post('/:project/apps/:app/revoke', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/revoke', idParams, B('codes.app.manage'), form, guard, async (req, res) => {
if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.confirm', detail: 'tick the box to confirm' }, { back: appBase(req) });
const got = await net(req, res, (t) => network.projects.revokeApp(t, req.params.project, req.params.app));
after(req, res, got, appBase(req), 'App revoked');
});
-- r.post('/:project/apps/:app/credentials/rotate', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/credentials/rotate', idParams, B('codes.app.credential'), form, guard, async (req, res) => {
const n = Number(req.body.overlap_seconds);
const body = Number.isInteger(n) && n >= 0 && n <= 604800 ? { overlap_seconds: n } : {};
const got = await net(req, res, (t) => network.projects.rotate(t, req.params.project, req.params.app, body));
@@@ -515,13 +519,13 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
credential: got.data.credential, rotated: true, previous: got.data.previous || [],
});
});
-- r.post('/:project/apps/:app/credentials/:credential/revoke', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/credentials/:credential/revoke', idParams, B('codes.app.credential'), form, guard, async (req, res) => {
if (!CRD_RE.test(req.params.credential)) return problemPage(req, res, { status: 404, code: 'credential.not_found', detail: 'no such credential' }, { back: appBase(req) });
const got = await net(req, res, (t) => network.projects.revokeCredential(t, req.params.project, req.params.app, req.params.credential));
after(req, res, got, appBase(req), 'Credential revoked');
});
-- r.post('/:project/apps/:app/grants', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/grants', idParams, B('codes.app.manage'), form, guard, async (req, res) => {
const capability = String(req.body.capability || '');
// Re-derive what the scope editor offers and refuse anything else before Network sees it.
const [project, catalog] = await Promise.all([
@@@ -537,7 +541,7 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
after(req, res, got, appBase(req), got.ok ? `${capability}: ${got.data.status}` : '');
});
for (const [action, decision] of [['approve', 'approved'], ['deny', 'denied'], ['revoke', 'revoked']]) {
-- r.post(`/:project/apps/:app/grants/:capability/${action}`, idParams, form, guard, async (req, res) => {
++ r.post(`/:project/apps/:app/grants/:capability/${action}`, idParams, B('codes.app.manage'), form, guard, async (req, res) => {
if (!CAP_RE.test(req.params.capability)) return problemPage(req, res, { status: 404, code: 'grant.not_found', detail: 'no such grant' }, { back: appBase(req) });
const got = await net(req, res, (t) => network.projects.decideGrant(t, req.params.project, req.params.app, req.params.capability, decision));
after(req, res, got, appBase(req), `${req.params.capability}: ${decision}`);
@@@ -594,7 +598,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
r.get('/:project/apps/:app/playground', idParams, (req, res) => playgroundPage(req, res));
-- r.post('/:project/apps/:app/playground/events', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/playground/events', idParams, B('codes.playground.run'), form, guard, async (req, res) => {
const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
const b = req.body || {};
@@@ -607,7 +611,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
});
-- r.post('/:project/apps/:app/playground/media', idParams, async (req, res) => {
++ r.post('/:project/apps/:app/playground/media', idParams, B('codes.playground.run'), async (req, res) => {
if (!sameOrigin(config, req)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
let parsed;
try { parsed = await readMultipart(req, config.playground.maxUploadBytes); } catch (err) {
@@@ -648,7 +652,7 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
}
r.get('/:project/apps/:app/releases/new', idParams, (req, res) => editorPage(req, res, { kind: req.query.kind }));
-- r.post('/:project/apps/:app/releases', idParams, form, guard, async (req, res) => {
++ r.post('/:project/apps/:app/releases', idParams, B('codes.release.create'), form, guard, async (req, res) => {
const b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : 'app';
const p = manifestsLib.parse(b.manifest);
@@@ -682,13 +686,15 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
* asked with the actor's own token for the release's project and app.
*/
function createReleaseActionRoutes(ctx) {
-- const { config, network, sso, releases, log } = ctx;
++ const { config, network, sso, releases, log, actorLimits } = ctx;
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '16kb' });
++ // Per-actor limit (http/actor-limits.js) before the form is read; the API's release actions share it.
++ const manage = actorLimits.budget('codes.release.manage');
const page = (req, res, o, status) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
for (const action of ['publish', 'deprecate', 'revoke']) {
-- r.post(`/:id/${action}`, form, async (req, res) => {
++ r.post(`/:id/${action}`, manage, form, async (req, res) => {
if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);
if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
ddiff --git a/server/http/tools.js b/server/http/tools.js
iindex 88a8022..6821405 100644
---- a/server/http/tools.js
++++ b/server/http/tools.js
@@@ -19,7 +19,10 @@ const manifests = require('../domain/manifests');
const { pkcePair } = require('../auth/sso');
function createToolRoutes(ctx) {
-- const { config, docs } = ctx;
++ const { config, docs, actorLimits } = ctx;
++ // Per-actor limits (http/actor-limits.js) after the per-address limiter, before the form is read.
++ const webhookLimit = actorLimits.budget('codes.webhook.tool');
++ const validateLimit = actorLimits.budget('codes.manifest.validate');
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '300kb' });
const limiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: true, legacyHeaders: false });
@@@ -141,12 +144,12 @@ ${v.parsed && v.parsed.note ? html`<dt>Shape</dt><dd>${v.parsed.note}</dd>` : ''
}
r.get('/tools/webhooks', (req, res) => webhookPage(req, res));
-- r.post('/tools/webhooks/verify', limiter, form, (req, res) => {
++ r.post('/tools/webhooks/verify', limiter, webhookLimit, form, (req, res) => {
const b = req.body || {};
const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', signature: b.signature, signatureV2: b.signature_v2, timestamp: b.timestamp, secret: typeof b.secret === 'string' ? b.secret : '' });
webhookPage(req, res, { status: v.accepted ? 200 : 422, verify: v, values: { body: b.body, signature: b.signature, signature_v2: b.signature_v2, timestamp: b.timestamp } });
});
-- r.post('/tools/webhooks/sample', limiter, form, (req, res) => {
++ r.post('/tools/webhooks/sample', limiter, webhookLimit, form, (req, res) => {
const b = req.body || {};
const type = eventTypes.includes(b.event_type) ? b.event_type : null;
if (!type || !b.secret) return webhookPage(req, res, { status: 422, values: { event_type: b.event_type } });
@@@ -170,7 +173,7 @@ ${result ? validationResult(result) : ''}`,
});
r.get('/manifests/validate', (req, res) => validatorPage(req, res, { kind: req.query.kind === 'mod' ? 'mod' : 'app' }));
-- r.post('/manifests/validate', limiter, form, (req, res) => {
++ r.post('/manifests/validate', limiter, validateLimit, form, (req, res) => {
const b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : 'app';
const p = manifests.parse(b.manifest);
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
nnew file mode 100644
iindex 0000000..52435db
---- /dev/null
++++ b/test/actor-limits.test.js
@@@ -0,0 +1,100 @@
++'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 person counts as themselves, an app
++ * token as its app, anyone else by address. Writes have their own, tighter numbers: a refused one
++ * never reaches Network. Health, ready, release.json and metrics are never limited; refusals are
++ * logged (no token) and counted.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { actor } = require('../server/http/actor-limits');
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const t = await boot({ actorLimits: true, limitsNow: () => clock, env: { CODES_LIMITS_MINUTE: '3', CODES_LIMITS_HOUR: '100' } });
++ const rosa = t.network.addUser('rosa');
++ const sam = t.network.addUser('sam');
++ const projectId = await t.project(rosa, 'Limits');
++ const app = await t.app(rosa, projectId, { name: 'Counted' });
++ const other = await t.app(rosa, projectId, { name: 'Other' });
++
++ await check('a portal read: 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('/projects', { as: rosa })).status, 200);
++ const asked = t.network.requests.length;
++ const r = await t.get('/projects', { as: rosa });
++ assert.strictEqual(r.status, 429, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('retry-after'), '45');
++ assert.ok(/^application\/problem\+json/.test(r.headers.get('content-type')), r.headers.get('content-type'));
++ const body = r.json();
++ assert.deepStrictEqual([body.code, body.status, body.retry_after_seconds], ['rate_limited', 429, 45]);
++ assert.ok(body.detail.includes('codes.portal.read'), body.detail);
++ assert.strictEqual(t.network.requests.length, asked, 'Network was not asked');
++ assert.strictEqual((await t.get('/projects', { as: sam })).status, 200, 'another person still passes');
++ });
++
++ await check('an API read: an app token counts as its app; signed-out callers by address', async () => {
++ const token = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.read']);
++ const path = `/api/v1/apps/${app.id}/trust`;
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${token}` } })).status, 200);
++ const r = await t.get(path, { headers: { authorization: `Bearer ${token}` } });
++ assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
++ const theirs = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.read']);
++ assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${theirs}` } })).status, 200, 'another app still passes');
++ const from = (ip) => t.get(path, { headers: { 'X-Forwarded-For': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
++ assert.strictEqual((await from('203.0.113.7')).status, 429);
++ assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 45 * 1000;
++ assert.strictEqual((await t.get('/projects', { as: rosa })).status, 200);
++ });
++
++ await check('a write has its own number: 5 new projects a minute, the 6th refused before Network hears of it', async () => {
++ clock = Date.UTC(2026, 8, 27, 12, 5, 0);
++ for (let i = 0; i < 4; i++) await t.project(sam, `Sam ${i}`);
++ await t.project(sam, 'Sam 4');
++ const asked = t.network.requests.length;
++ const r = await t.get('/projects', { as: sam, form: { name: 'Sam 5' } });
++ assert.deepStrictEqual([r.status, r.json().code, r.headers.get('retry-after')], [429, 'rate_limited', '60']);
++ assert.ok(r.json().detail.includes('codes.project.create'), r.json().detail);
++ assert.strictEqual(t.network.requests.length, asked, 'Network was not asked');
++ const mine = await t.get('/projects', { as: rosa, form: { name: 'Rosa 2' } });
++ assert.strictEqual(mine.status, 303, 'another person still creates');
++ });
++
++ await check('health, ready, release.json and metrics are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ }
++ });
++
++ await check('refusals are counted in codes_rate_limited_total and logged without a token', async () => {
++ const m = (await t.get('/metrics')).text;
++ const counted = m.split('\n').filter((l) => l.includes('codes_rate_limited_total')).join('\n');
++ assert.ok(/codes_rate_limited_total\{limit="codes.portal.read",window="minute"\} 1/.test(m), counted);
++ assert.ok(/codes_rate_limited_total\{limit="codes.read",window="minute"\} 2/.test(m), counted);
++ assert.ok(/codes_rate_limited_total\{limit="codes.project.create",window="minute"\} 1/.test(m), counted);
++ const logs = t.logs();
++ assert.ok(logs.includes(`[Limits] codes.portal.read: user:${rosa.subject} refused`), 'one log line per refusal');
++ assert.ok(logs.includes(`[Limits] codes.read: app:${app.id} refused`));
++ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');
++ });
++
++ await check('who is counted', () => {
++ assert.strictEqual(actor({ principal: { sub: 'app:app_1' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_1');
++ assert.strictEqual(actor({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
++ assert.strictEqual(actor({ principal: { legacy: true }, viewer: { kind: 'anonymous' }, ip: '203.0.113.1' }), 'ip:203.0.113.1');
++ assert.strictEqual(actor({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 3f616e4..03f008b 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
-- assert.strictEqual(sdkPkg.version, '0.11.0');
++ assert.strictEqual(sdkPkg.version, '0.12.0');
// The contracts tag v0.53.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 1a14f5c..3b5cb94 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -1,6 +1,8 @@
'use strict';
// The docs tests fetch one page per contract (hundreds); the per-address limit would answer 429 part-way.
process.env.CODES_RATE_LIMIT_PER_MIN = process.env.CODES_RATE_LIMIT_PER_MIN || '100000';
++// For the same reason the per-actor limits (server/http/actor-limits.js) count nobody unless a test
++// asks for them: boot({ actorLimits: true, limitsNow }) (test/actor-limits.test.js).
/**
* Boots Codes on a temp database with mocks of Network, Events and Media, captures every log line
* (the app's logger AND console), and returns a small HTTP client. Every test file gets its own.
@@@ -45,7 +47,7 @@ async function boot(opts = {}) {
const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
const config = configLib.load(env);
-- const built = createApp({ config, log });
++ const built = createApp({ config, log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });