openvibers/openvibe.host Commits
Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). server/http/actor-limits.js runs after the viewer is resolved and counts a signed-in person by subject, or a service by its principal (a service here manages its own projects): HOST_LIMITS_MINUTE / _HOUR (120 and 3000), and tighter on top for deploy uploads (10 a minute, 100 an hour: stored and unpacked), domain verification (10/100: DNS lookups) and new projects (5/30). Past a limit: 429 rate_limited with Retry-After before the route runs, logged and counted in host_rate_limited_total. Anonymous callers and reads keep the per-address limits. HOST_ACTOR_LIMITS=off is a rollback lever; the Stage B suites, which deploy dozens of times a minute as one person, boot with it off. test/actor-limits.test.js covers the limits themselves. npm test 38/38.
Changes
@@@ -50,6 +50,12 @@ HOST_FORM_SECRET=# Deploys are refused (507) while the object store's filesystem has less than this free: the disk# is shared with every other service on the host. Serving is never affected. Default 5 GiB.#HOST_MIN_FREE_BYTES=5368709120++# Per-actor limits on dashboard and API writes (server/http/actor-limits.js, roadmap WS-R task 4): per minute and per++# hour for a signed-in person or a service; deploy uploads, domain checks and new projects have tighter numbers.++# HOST_ACTOR_LIMITS=off turns them off (a rollback lever).++#HOST_LIMITS_MINUTE=120++#HOST_LIMITS_HOUR=3000++#HOST_ACTOR_LIMITS=on# Projects one person may own; each gets the per-project quotas below. Default 10.#HOST_MAX_PROJECTS_PER_OWNER=10@@@ -17,7 +17,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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"},"bin": {@@@ -1428,9 +1428,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"@@@ -32,7 +32,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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": {@@@ -96,6 +96,8 @@ function createApp(opts = {}) {const registry = sharedMetrics.createRegistry();const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });const proc = sharedMetrics.processMetrics(registry);++ // Per-actor limits on writes (server/http/actor-limits.js; roadmap WS-R task 4), one budget for the API and the dashboard.++ ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry });sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });registry.gauge({name: 'host_sites', help: 'Active tenant sites',nnew file mode 100644@@@ -0,0 +1,56 @@++/**++ * Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk/limits).++ *++ * A signed-in person (req.viewer.kind === 'user') is counted by subject; a service token by its principal (a service++ * here manages its own projects, not other people's). Anonymous callers and reads are not counted: the per-address++ * limits in app.js stay. Every write takes HOST_LIMITS_MINUTE / HOST_LIMITS_HOUR (120 and 3000); deploy uploads++ * (stored and unpacked), domain verification (DNS lookups) and project creation have tighter numbers on top. Past a++ * limit: 429 problem+json `rate_limited` with Retry-After before the route runs, logged and counted in++ * host_rate_limited_total{limit,window}. Counters live in this process.++ */++'use strict';++++const { createActorLimiter } = require('openvibe-sdk/limits');++++const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };++++/** [name, method regex, path regex (relative to the router), { minute, hour }]; the first match wins. */++const ROUTES = [++ ['host.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],++ ['host.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],++ ['host.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],++];++++function createHostActorLimits({ env = process.env, registry = null, now } = {}) {++ // HOST_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).++ if (/^(off|0|false)$/i.test(String(env.HOST_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };++ const refused = registry && typeof registry.counter === 'function'++ ? registry.counter({ name: 'host_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })++ : null;++ const actor = (req) => {++ const v = req.viewer;++ if (!v) return null;++ if (v.kind === 'user' && v.subject) return `user:${v.subject}`;++ if (v.kind === 'service' && v.claims && v.claims.sub) return String(v.claims.sub);++ return null;++ };++ const limits = createActorLimiter({++ limits: { minute: num(env.HOST_LIMITS_MINUTE, 120), hour: num(env.HOST_LIMITS_HOUR, 3000) },++ actor,++ ...(now ? { now } : {}),++ onLimited(e) {++ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);++ if (refused) refused.inc({ limit: e.name, window: e.window });++ },++ });++ const write = limits('host.write');++ const named = ROUTES.map(([name, method, pathRe, own]) => ({ method, pathRe, mw: limits(name, own) }));++ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);++ return function hostActorLimits(req, res, next) {++ if (!WRITE.has(req.method)) return next();++ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(req.path));++ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));++ };++}++++module.exports = { createHostActorLimits, ROUTES };@@@ -43,6 +43,7 @@ function createApi(ctx) {next();});router.use(viewers.middleware('api'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);const siteUrl = (hostname) => {const u = new URL(config.baseUrl);@@@ -25,6 +25,7 @@ function createDashboard(ctx) {};router.use(viewers.middleware('dashboard'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);router.use((req, res, next) => { privateNoStore(res); next(); });function page(req, res, status, title, body, notice, { indexable = false } = {}) {nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++// Per-actor limits on Host's writes (server/http/actor-limits.js; roadmap WS-R task 4): a signed-in person is counted++// by subject and a service by its principal; past the limit 429 rate_limited with Retry-After before the route runs,++// while another actor passes; deploy uploads have their own tighter number; reads and anonymous callers are never++// counted; the window reopens.++// node test/actor-limits.test.js++const assert = require('assert');++const http = require('http');++const express = require('express');++const { createHostActorLimits } = require('../server/http/actor-limits');++++console.warn = () => {};++(async () => {++ let t = Date.UTC(2026, 8, 28, 3, 0, 0);++ const app = express();++ app.use((req, res, next) => {++ const who = req.headers['x-who'] || '';++ req.viewer = who.startsWith('svc:') ? { kind: 'service', claims: { sub: who } } : who ? { kind: 'user', subject: who } : { kind: 'anonymous' };++ next();++ });++ app.use(createHostActorLimits({ env: { HOST_LIMITS_MINUTE: '5' }, now: () => t }));++ let ran = 0;++ app.all('*', (req, res) => { ran++; res.json({ ok: true }); });++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (method, p, who) => {++ const r = await fetch(base + p, { method, headers: who ? { 'x-who': who } : {} });++ return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json().catch(() => ({})) };++ };++ try {++ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200);++ const before = ran;++ const r = await call('POST', '/sites/s1/rollback', 'usr_a');++ assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);++ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'svc:tools')).status, 200, 'a service is its own actor');++ for (let i = 0; i < 10; i++) {++ assert.strictEqual((await call('GET', '/projects', 'usr_a')).status, 200, 'reads are never counted');++ assert.strictEqual((await call('POST', '/projects')).status, 200, 'anonymous callers keep the address limit');++ }++ t += 60 * 1000;++ const ups = [];++ for (let i = 0; i < 6; i++) ups.push((await call('POST', '/sites/s1/deploys', 'usr_b')).status);++ assert.deepStrictEqual(ups.slice(0, 5), [200, 200, 200, 200, 200]);++ t += 60 * 1000;++ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200, 'the next minute');++ } finally {++ server.close();++ }++ assert.strictEqual(require('../server/http/actor-limits').createHostActorLimits({ env: { HOST_ACTOR_LIMITS: 'off' } }).name, 'noLimits', 'the rollback lever turns them off');++ console.log('actor limits: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -16,6 +16,10 @@ const http = require('http');const { startNetwork } = require('./mocks');const { site: siteTar } = require('./tar');++// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own++// (test/actor-limits.test.js), so they are off here unless a test turns them on.++if (process.env.HOST_ACTOR_LIMITS === undefined) process.env.HOST_ACTOR_LIMITS = 'off';++function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {let t = start;return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
@@ -50,6 +50,12 @@ HOST_FORM_SECRET=
@@ -50,6 +50,12 @@ HOST_FORM_SECRET=
# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
# Deploys are refused (507) while the object store's filesystem has less than this free: the disk
# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
# is shared with every other service on the host. Serving is never affected. Default 5 GiB.
#HOST_MIN_FREE_BYTES=5368709120
#HOST_MIN_FREE_BYTES=5368709120
+# Per-actor limits on dashboard and API writes (server/http/actor-limits.js, roadmap WS-R task 4): per minute and per
+# hour for a signed-in person or a service; deploy uploads, domain checks and new projects have tighter numbers.
+# HOST_ACTOR_LIMITS=off turns them off (a rollback lever).
+#HOST_LIMITS_MINUTE=120
+#HOST_LIMITS_HOUR=3000
+#HOST_ACTOR_LIMITS=on
# Projects one person may own; each gets the per-project quotas below. Default 10.
# Projects one person may own; each gets the per-project quotas below. Default 10.
#HOST_MAX_PROJECTS_PER_OWNER=10
#HOST_MAX_PROJECTS_PER_OWNER=10
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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"
},
},
"bin": {
"bin": {
@@ -1428,9 +1428,9 @@
@@ -1428,9 +1428,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"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.66.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": {
@@ -96,6 +96,8 @@ function createApp(opts = {}) {
@@ -96,6 +96,8 @@ function createApp(opts = {}) {
const registry = sharedMetrics.createRegistry();
const registry = sharedMetrics.createRegistry();
const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });
const httpMetrics = sharedMetrics.httpMetrics(registry, { normalize: (req) => (req.hostTarget === 'site' ? 'tenant_site' : req.hostTarget === 'unknown' ? 'unknown_host' : null) });
const proc = sharedMetrics.processMetrics(registry);
const proc = sharedMetrics.processMetrics(registry);
+ // Per-actor limits on writes (server/http/actor-limits.js; roadmap WS-R task 4), one budget for the API and the dashboard.
+ ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry });
sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });
sharedMetrics.releaseInfo(registry, { service: 'host', release: release.release });
registry.gauge({
registry.gauge({
name: 'host_sites', help: 'Active tenant sites',
name: 'host_sites', help: 'Active tenant sites',
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+/**
+ * Per-actor limits on the dashboard's and the API's writes (roadmap WS-R task 4; openvibe-sdk/limits).
+ *
+ * A signed-in person (req.viewer.kind === 'user') is counted by subject; a service token by its principal (a service
+ * here manages its own projects, not other people's). Anonymous callers and reads are not counted: the per-address
+ * limits in app.js stay. Every write takes HOST_LIMITS_MINUTE / HOST_LIMITS_HOUR (120 and 3000); deploy uploads
+ * (stored and unpacked), domain verification (DNS lookups) and project creation have tighter numbers on top. Past a
+ * limit: 429 problem+json `rate_limited` with Retry-After before the route runs, logged and counted in
+ * host_rate_limited_total{limit,window}. Counters live in this process.
+ */
+'use strict';
+
+const { createActorLimiter } = require('openvibe-sdk/limits');
+
+const num = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : d; };
+
+/** [name, method regex, path regex (relative to the router), { minute, hour }]; the first match wins. */
+const ROUTES = [
+ ['host.deploy.upload', /^POST$/, /^\/sites\/[^/]+\/deploys\/?$/, { minute: 10, hour: 100 }],
+ ['host.domain.verify', /^POST$/, /^\/domains\/[^/]+\/verify$/, { minute: 10, hour: 100 }],
+ ['host.project.create', /^POST$/, /^\/projects\/?$/, { minute: 5, hour: 30 }],
+];
+
+function createHostActorLimits({ env = process.env, registry = null, now } = {}) {
+ // HOST_ACTOR_LIMITS=off: a rollback lever (and what the Stage B suites use: they deploy dozens of times a minute).
+ if (/^(off|0|false)$/i.test(String(env.HOST_ACTOR_LIMITS || ''))) return function noLimits(req, res, next) { next(); };
+ const refused = registry && typeof registry.counter === 'function'
+ ? registry.counter({ name: 'host_rate_limited_total', help: 'Writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
+ : null;
+ const actor = (req) => {
+ const v = req.viewer;
+ if (!v) return null;
+ if (v.kind === 'user' && v.subject) return `user:${v.subject}`;
+ if (v.kind === 'service' && v.claims && v.claims.sub) return String(v.claims.sub);
+ return null;
+ };
+ const limits = createActorLimiter({
+ limits: { minute: num(env.HOST_LIMITS_MINUTE, 120), hour: num(env.HOST_LIMITS_HOUR, 3000) },
+ actor,
+ ...(now ? { now } : {}),
+ onLimited(e) {
+ console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
+ if (refused) refused.inc({ limit: e.name, window: e.window });
+ },
+ });
+ const write = limits('host.write');
+ const named = ROUTES.map(([name, method, pathRe, own]) => ({ method, pathRe, mw: limits(name, own) }));
+ const WRITE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+ return function hostActorLimits(req, res, next) {
+ if (!WRITE.has(req.method)) return next();
+ const own = named.find((r) => r.method.test(req.method) && r.pathRe.test(req.path));
+ return write(req, res, (err) => (err ? next(err) : own ? own.mw(req, res, next) : next()));
+ };
+}
+
+module.exports = { createHostActorLimits, ROUTES };
@@ -43,6 +43,7 @@ function createApi(ctx) {
@@ -43,6 +43,7 @@ function createApi(ctx) {
next();
next();
});
});
router.use(viewers.middleware('api'));
router.use(viewers.middleware('api'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
const siteUrl = (hostname) => {
const siteUrl = (hostname) => {
const u = new URL(config.baseUrl);
const u = new URL(config.baseUrl);
@@ -25,6 +25,7 @@ function createDashboard(ctx) {
@@ -25,6 +25,7 @@ function createDashboard(ctx) {
};
};
router.use(viewers.middleware('dashboard'));
router.use(viewers.middleware('dashboard'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
router.use((req, res, next) => { privateNoStore(res); next(); });
function page(req, res, status, title, body, notice, { indexable = false } = {}) {
function page(req, res, status, title, body, notice, { indexable = false } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+// Per-actor limits on Host's writes (server/http/actor-limits.js; roadmap WS-R task 4): a signed-in person is counted
+// by subject and a service by its principal; past the limit 429 rate_limited with Retry-After before the route runs,
+// while another actor passes; deploy uploads have their own tighter number; reads and anonymous callers are never
+// counted; the window reopens.
+// node test/actor-limits.test.js
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { createHostActorLimits } = require('../server/http/actor-limits');
+
+console.warn = () => {};
+(async () => {
+ let t = Date.UTC(2026, 8, 28, 3, 0, 0);
+ const app = express();
+ app.use((req, res, next) => {
+ const who = req.headers['x-who'] || '';
+ req.viewer = who.startsWith('svc:') ? { kind: 'service', claims: { sub: who } } : who ? { kind: 'user', subject: who } : { kind: 'anonymous' };
+ next();
+ });
+ app.use(createHostActorLimits({ env: { HOST_LIMITS_MINUTE: '5' }, now: () => t }));
+ let ran = 0;
+ app.all('*', (req, res) => { ran++; res.json({ ok: true }); });
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (method, p, who) => {
+ const r = await fetch(base + p, { method, headers: who ? { 'x-who': who } : {} });
+ return { status: r.status, retry: r.headers.get('retry-after'), body: await r.json().catch(() => ({})) };
+ };
+ try {
+ for (let i = 0; i < 5; i++) assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200);
+ const before = ran;
+ const r = await call('POST', '/sites/s1/rollback', 'usr_a');
+ assert.deepStrictEqual([r.status, r.body.code, Number(r.retry) > 0, ran], [429, 'rate_limited', true, before]);
+ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'svc:tools')).status, 200, 'a service is its own actor');
+ for (let i = 0; i < 10; i++) {
+ assert.strictEqual((await call('GET', '/projects', 'usr_a')).status, 200, 'reads are never counted');
+ assert.strictEqual((await call('POST', '/projects')).status, 200, 'anonymous callers keep the address limit');
+ }
+ t += 60 * 1000;
+ const ups = [];
+ for (let i = 0; i < 6; i++) ups.push((await call('POST', '/sites/s1/deploys', 'usr_b')).status);
+ assert.deepStrictEqual(ups.slice(0, 5), [200, 200, 200, 200, 200]);
+ t += 60 * 1000;
+ assert.strictEqual((await call('POST', '/sites/s1/rollback', 'usr_a')).status, 200, 'the next minute');
+ } finally {
+ server.close();
+ }
+ assert.strictEqual(require('../server/http/actor-limits').createHostActorLimits({ env: { HOST_ACTOR_LIMITS: 'off' } }).name, 'noLimits', 'the rollback lever turns them off');
+ console.log('actor limits: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -16,6 +16,10 @@ const http = require('http');
@@ -16,6 +16,10 @@ const http = require('http');
const { startNetwork } = require('./mocks');
const { startNetwork } = require('./mocks');
const { site: siteTar } = require('./tar');
const { site: siteTar } = require('./tar');
+// The Stage B suites deploy dozens of times a minute as one person: per-actor limits are tested on their own
+// (test/actor-limits.test.js), so they are off here unless a test turns them on.
+if (process.env.HOST_ACTOR_LIMITS === undefined) process.env.HOST_ACTOR_LIMITS = 'off';
+
function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
function makeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
let t = start;
let t = start;
return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };
return { now: () => t, advance: (ms) => { t += ms; return t; }, set: (v) => { t = v; } };