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}.
Changes
@@@ -6,6 +6,11 @@ HOST=127.0.0.1BASE_URL=https://openvibe.codesTRUST_PROXY=2CODES_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@@@ -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)@@@ -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"@@@ -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": {@@@ -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({@@@ -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',nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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}` } });@@@ -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}/mres.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}/revocredential: 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}/revoafter(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);@@@ -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);nnew file mode 100644@@@ -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();++})();@@@ -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'));@@@ -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)); });
@@ -6,6 +6,11 @@ HOST=127.0.0.1
@@ -6,6 +6,11 @@ HOST=127.0.0.1
BASE_URL=https://openvibe.codes
BASE_URL=https://openvibe.codes
TRUST_PROXY=2
TRUST_PROXY=2
CODES_DB_PATH=/var/lib/openvibe-codes/codes.db
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.
# OpenVibe.Network: SSO (OAuth client `codes`, PKCE S256), JWKS, the /api/v1/projects API, the registry.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
@@ -64,7 +64,37 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
@@ -64,7 +64,37 @@ For the playgrounds to succeed end to end (not Codes' code; configuration elsewh
## Configuration
## 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)
## Deploy (for the lead)
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -1039,9 +1039,9 @@
@@ -1039,9 +1039,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"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
"engines": {
"engines": {
@@ -38,13 +38,17 @@ const { createPageRoutes } = require('./http/pages');
@@ -38,13 +38,17 @@ const { createPageRoutes } = require('./http/pages');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createCodesReadiness } = require('./observability');
const { createCodesReadiness } = require('./observability');
+const { createActorLimits } = require('./http/actor-limits');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
-/** opts: config, store | dbPath, now (clock), fetchImpl, 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 = {}) {
function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
@@ -72,6 +76,9 @@ function createApp(opts = {}) {
@@ -72,6 +76,9 @@ function createApp(opts = {}) {
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'codes', release: release.release });
const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'codes', release: release.release });
app.locals.metrics = metrics.registry;
app.locals.metrics = metrics.registry;
app.locals.ctx = ctx;
app.locals.ctx = ctx;
+ // Per-actor limits (http/actor-limits.js) 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(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -33,6 +33,12 @@ function load(env = process.env) {
@@ -33,6 +33,12 @@ function load(env = process.env) {
isProduction,
isProduction,
baseUrl,
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ // Per-actor limits (server/http/actor-limits.js, roadmap WS-R task 4): the 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',
dbPath: env.CODES_DB_PATH || './data/codes.db',
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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 };
@@ -32,18 +32,22 @@ const MANAGE = 'codes.release.manage';
@@ -32,18 +32,22 @@ const MANAGE = 'codes.release.manage';
const READ = 'codes.release.read';
const READ = 'codes.release.read';
function createApi(ctx) {
function createApi(ctx) {
- const { config, docs, releases, trust, keys } = ctx;
+ const { config, docs, releases, trust, keys, actorLimits } = ctx;
const r = asyncRouter();
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(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const json = express.json({ limit: '96kb' });
const json = express.json({ limit: '96kb' });
const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
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 });
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 });
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 b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : (b.kind === 'app' ? 'app' : null);
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');
if (!kind) return problem(req, res, 422, 'manifest.kind', 'kind is app or mod');
@@ -62,17 +66,17 @@ function createApi(ctx) {
@@ -62,17 +66,17 @@ function createApi(ctx) {
const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
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>'))];
: 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');
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.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) });
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');
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.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' });
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;
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');
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
@@ -104,7 +108,7 @@ function createApi(ctx) {
@@ -104,7 +108,7 @@ function createApi(ctx) {
return problem(req, res, 500, 'internal.error', 'Internal error');
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');
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);
const p = appPrincipal(req, res, req.params.app);
if (!p) return;
if (!p) return;
@@ -120,7 +124,7 @@ function createApi(ctx) {
@@ -120,7 +124,7 @@ function createApi(ctx) {
});
});
for (const action of ['publish', 'deprecate', 'revoke']) {
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;
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');
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
const p = appPrincipal(req, res, rel.app_id);
const p = appPrincipal(req, res, rel.app_id);
@@ -253,7 +253,8 @@ ${signedIn ? releaseActions(req, rel) : ''}
@@ -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)]))}`,
<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);
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 {
try {
trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
@@ -41,8 +41,9 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -41,8 +41,9 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
function createPortalRoutes(ctx) {
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 r = asyncRouter();
+ const B = (name) => actorLimits.budget(name);
const form = express.urlencoded({ extended: false, limit: '96kb' });
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 });
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>` : ''}`,
@@ -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);
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();
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.
// Every state change: same-origin and a valid form token.
const guard = (req, res, next) => {
const guard = (req, res, next) => {
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
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
@@ -111,7 +115,7 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
}, status);
}, status);
}
}
r.get('/', (req, res) => projectsPage(req, res));
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 || '') }));
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 });
if (!got.ok) return projectsPage(req, res, { problem: got.problem, status: got.problem.status });
res.redirect(303, `/projects/${got.data.id}`);
res.redirect(303, `/projects/${got.data.id}`);
@@ -213,30 +217,30 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
@@ -213,30 +217,30 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
res.redirect(303, `${target}?done=${encodeURIComponent(done)}`);
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 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') }));
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}`);
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) });
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 || '') }));
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');
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) });
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 self = req.params.subject === req.viewer.subject;
const got = await net(req, res, (t) => network.projects.removeMember(t, req.params.project, req.params.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');
if (got.ok && self) return res.redirect(303, '/projects');
after(req, res, got, back(req), 'Member removed');
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) });
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));
const got = await net(req, res, (t) => network.projects.archive(t, req.params.project));
after(req, res, got, back(req), 'Project archived');
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);
const project = await loadProject(req, res);
if (!project) return;
if (!project) return;
const got = await net(req, res, (t) => buildExport({
const got = await net(req, res, (t) => buildExport({
@@ -252,7 +256,7 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
@@ -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).
// 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.
// One at a time per project: it reads every object and event the project holds.
const exporting = new Set();
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);
const project = await loadProject(req, res);
if (!project) return;
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) });
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
@@ -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);
const project = await loadProject(req, res);
if (!project) return;
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) });
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({
@@ -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 = {
const body = {
name: String(req.body.name || ''), environment: String(req.body.environment || 'sandbox'),
name: String(req.body.name || ''), environment: String(req.body.environment || 'sandbox'),
type: String(req.body.type || 'confidential'), redirect_uris: redirectList(req.body.redirect_uris),
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
@@ -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}`;
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) }));
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');
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) });
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));
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');
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 n = Number(req.body.overlap_seconds);
const body = Number.isInteger(n) && n >= 0 && n <= 604800 ? { overlap_seconds: n } : {};
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));
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
@@ -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 || [],
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) });
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));
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');
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 || '');
const capability = String(req.body.capability || '');
// Re-derive what the scope editor offers and refuse anything else before Network sees it.
// Re-derive what the scope editor offers and refuse anything else before Network sees it.
const [project, catalog] = await Promise.all([
const [project, catalog] = await Promise.all([
@@ -537,7 +541,7 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
@@ -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}` : '');
after(req, res, got, appBase(req), got.ok ? `${capability}: ${got.data.status}` : '');
});
});
for (const [action, decision] of [['approve', 'approved'], ['deny', 'denied'], ['revoke', 'revoked']]) {
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) });
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));
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}`);
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>`
@@ -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.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));
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) });
if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
const b = req.body || {};
const b = req.body || {};
@@ -607,7 +611,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
@@ -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) });
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);
if (!sameOrigin(config, req)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
let parsed;
let parsed;
try { parsed = await readMultipart(req, config.playground.maxUploadBytes); } catch (err) {
try { parsed = await readMultipart(req, config.playground.maxUploadBytes); } catch (err) {
@@ -648,7 +652,7 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
@@ -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.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 b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : 'app';
const kind = b.kind === 'mod' ? 'mod' : 'app';
const p = manifestsLib.parse(b.manifest);
const p = manifestsLib.parse(b.manifest);
@@ -682,13 +686,15 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
@@ -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.
* asked with the actor's own token for the release's project and app.
*/
*/
function createReleaseActionRoutes(ctx) {
function createReleaseActionRoutes(ctx) {
- const { config, network, sso, releases, log } = ctx;
+ const { config, network, sso, releases, log, actorLimits } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '16kb' });
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 });
const page = (req, res, o, status) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
for (const action of ['publish', 'deprecate', 'revoke']) {
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 (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 (!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);
if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
@@ -19,7 +19,10 @@ const manifests = require('../domain/manifests');
@@ -19,7 +19,10 @@ const manifests = require('../domain/manifests');
const { pkcePair } = require('../auth/sso');
const { pkcePair } = require('../auth/sso');
function createToolRoutes(ctx) {
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 r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '300kb' });
const form = express.urlencoded({ extended: false, limit: '300kb' });
const limiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: true, legacyHeaders: false });
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>` : ''
@@ -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.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 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 : '' });
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 } });
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 b = req.body || {};
const type = eventTypes.includes(b.event_type) ? b.event_type : null;
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 } });
if (!type || !b.secret) return webhookPage(req, res, { status: 422, values: { event_type: b.event_type } });
@@ -170,7 +173,7 @@ ${result ? validationResult(result) : ''}`,
@@ -170,7 +173,7 @@ ${result ? validationResult(result) : ''}`,
});
});
r.get('/manifests/validate', (req, res) => validatorPage(req, res, { kind: req.query.kind === 'mod' ? 'mod' : 'app' }));
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 b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : 'app';
const kind = b.kind === 'mod' ? 'mod' : 'app';
const p = manifests.parse(b.manifest);
const p = manifests.parse(b.manifest);
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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();
+})();
@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -22,7 +22,7 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').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.
// 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.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
assert.ok(contracts.resolve('codes.app-manifest@1'));
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
'use strict';
'use strict';
// The docs tests fetch one page per contract (hundreds); the per-address limit would answer 429 part-way.
// 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';
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
* 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.
* (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 = {}) {
@@ -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 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 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();
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
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)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });