@bongos/core 1.19.1046 → 1.19.1048
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +26 -21
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +19 -0
- package/modules/platform-identity/read-rate-limit.js +5 -1
- package/modules/platform-identity/routes/public-profile.js +4 -0
- package/modules/provisioning/rate-limit.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/go-live.js +101 -5
- package/src/bongos/middleware/rate-limit.js +151 -4
- package/src/bongos/routes/healthz.js +2 -1
- package/src/bongos/routes.js +6 -2
- package/src/module-api.js +1 -1
- package/tests/go_live.mjs +143 -4
- package/tests/read_default_meter.mjs +194 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1048",
|
|
6
|
+
"core_contract": "1.19.1048",
|
|
7
|
+
"source_commit": "64aa3088cc068647e5de41e2f87ab9fc416debb7",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-27T01:33:19.203Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 543,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2513,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3083,
|
|
20
|
+
"tree_sha256": "6f357e234dc4c052c43532cc7d280e9a51fa2731009c99e74eda18ec0ef06164",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "425c39cac8e66f234ff1558841f45fa8d33eaad6311b5ee3c0f6ebaa7e9dc7fe"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -2892,7 +2892,7 @@
|
|
|
2892
2892
|
{
|
|
2893
2893
|
"path": "docs/recipes/core-release-pipeline.md",
|
|
2894
2894
|
"mode": "0000644",
|
|
2895
|
-
"sha256": "
|
|
2895
|
+
"sha256": "1558473dea0e6c41e623ede06f0bf006fcd58cb1ab388b7e7e149872fcdbb00b"
|
|
2896
2896
|
},
|
|
2897
2897
|
{
|
|
2898
2898
|
"path": "docs/recipes/failed-ship-recovery-proof.md",
|
|
@@ -6812,7 +6812,7 @@
|
|
|
6812
6812
|
{
|
|
6813
6813
|
"path": "modules/platform-identity/read-rate-limit.js",
|
|
6814
6814
|
"mode": "0000644",
|
|
6815
|
-
"sha256": "
|
|
6815
|
+
"sha256": "dc11b89460e4cca22af1ab61672104ac99298f477c1bad890d688f87456494fa"
|
|
6816
6816
|
},
|
|
6817
6817
|
{
|
|
6818
6818
|
"path": "modules/platform-identity/recruiter-sliver.js",
|
|
@@ -6862,7 +6862,7 @@
|
|
|
6862
6862
|
{
|
|
6863
6863
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
6864
6864
|
"mode": "0000644",
|
|
6865
|
-
"sha256": "
|
|
6865
|
+
"sha256": "141aab9e7f1bd513d4d3c5d6e6ef51aa2e3cbfd138ee7fae60be6f52cfedb080"
|
|
6866
6866
|
},
|
|
6867
6867
|
{
|
|
6868
6868
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
@@ -7152,7 +7152,7 @@
|
|
|
7152
7152
|
{
|
|
7153
7153
|
"path": "modules/provisioning/rate-limit.js",
|
|
7154
7154
|
"mode": "0000644",
|
|
7155
|
-
"sha256": "
|
|
7155
|
+
"sha256": "b9cad1df215eba3dc415dc5a4ac8ade054dd53a823ce0ad0a5e5303be84df876"
|
|
7156
7156
|
},
|
|
7157
7157
|
{
|
|
7158
7158
|
"path": "modules/provisioning/recommendations.js",
|
|
@@ -8497,12 +8497,12 @@
|
|
|
8497
8497
|
{
|
|
8498
8498
|
"path": "package-lock.json",
|
|
8499
8499
|
"mode": "0000644",
|
|
8500
|
-
"sha256": "
|
|
8500
|
+
"sha256": "f8821b1b474225bcb1658d4ccee4aca9c9439f6757ee8624f36643af567d39e0"
|
|
8501
8501
|
},
|
|
8502
8502
|
{
|
|
8503
8503
|
"path": "package.json",
|
|
8504
8504
|
"mode": "0000644",
|
|
8505
|
-
"sha256": "
|
|
8505
|
+
"sha256": "5fa9fe3e161288c87dacff6bc04ef6a4f9c1e7c0f0f5b8684ec2c887dd289484"
|
|
8506
8506
|
},
|
|
8507
8507
|
{
|
|
8508
8508
|
"path": "public-docs/index.html",
|
|
@@ -8522,7 +8522,7 @@
|
|
|
8522
8522
|
{
|
|
8523
8523
|
"path": "release-notes.json",
|
|
8524
8524
|
"mode": "0000644",
|
|
8525
|
-
"sha256": "
|
|
8525
|
+
"sha256": "4b8836320c211dcd59f9f978755c247998fbc3bbbfc78d4aaa073a3051f367f0"
|
|
8526
8526
|
},
|
|
8527
8527
|
{
|
|
8528
8528
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9197,7 +9197,7 @@
|
|
|
9197
9197
|
{
|
|
9198
9198
|
"path": "scripts/gds/go-live.js",
|
|
9199
9199
|
"mode": "0000644",
|
|
9200
|
-
"sha256": "
|
|
9200
|
+
"sha256": "cf1e7da0a1a386a73c5ecf9bb37b2e3f593217bf8250f574cf95b9fbf73b1910"
|
|
9201
9201
|
},
|
|
9202
9202
|
{
|
|
9203
9203
|
"path": "scripts/gds/goal-names.js",
|
|
@@ -10377,7 +10377,7 @@
|
|
|
10377
10377
|
{
|
|
10378
10378
|
"path": "src/bongos/middleware/rate-limit.js",
|
|
10379
10379
|
"mode": "0000644",
|
|
10380
|
-
"sha256": "
|
|
10380
|
+
"sha256": "2c4cb05109bd3a931acba1967e1d01e4c3c069273d22b4356211aac103a04781"
|
|
10381
10381
|
},
|
|
10382
10382
|
{
|
|
10383
10383
|
"path": "src/bongos/module-overrides.js",
|
|
@@ -10452,7 +10452,7 @@
|
|
|
10452
10452
|
{
|
|
10453
10453
|
"path": "src/bongos/routes.js",
|
|
10454
10454
|
"mode": "0000644",
|
|
10455
|
-
"sha256": "
|
|
10455
|
+
"sha256": "c165e3ee08a2d48ab16f5b9889b26ed831131439ea69368f2eab512d4680dee2"
|
|
10456
10456
|
},
|
|
10457
10457
|
{
|
|
10458
10458
|
"path": "src/bongos/routes/CLAUDE.md",
|
|
@@ -10497,7 +10497,7 @@
|
|
|
10497
10497
|
{
|
|
10498
10498
|
"path": "src/bongos/routes/healthz.js",
|
|
10499
10499
|
"mode": "0000644",
|
|
10500
|
-
"sha256": "
|
|
10500
|
+
"sha256": "6adb006126407f87e3d57d7a9c6c283fcc0d713d6f8c4ce0ba403692edf7fea2"
|
|
10501
10501
|
},
|
|
10502
10502
|
{
|
|
10503
10503
|
"path": "src/bongos/routes/instance.js",
|
|
@@ -10592,7 +10592,7 @@
|
|
|
10592
10592
|
{
|
|
10593
10593
|
"path": "src/module-api.js",
|
|
10594
10594
|
"mode": "0000644",
|
|
10595
|
-
"sha256": "
|
|
10595
|
+
"sha256": "097ef7d2d748ad98273257de5771d10d2ffa523e558ff8b3de6b039aeecfab1f"
|
|
10596
10596
|
},
|
|
10597
10597
|
{
|
|
10598
10598
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12102,7 +12102,7 @@
|
|
|
12102
12102
|
{
|
|
12103
12103
|
"path": "tests/go_live.mjs",
|
|
12104
12104
|
"mode": "0000644",
|
|
12105
|
-
"sha256": "
|
|
12105
|
+
"sha256": "e62b39da9f1e5538dfc4d642a82c75ac400885b371170e722690b3adc756d1f5"
|
|
12106
12106
|
},
|
|
12107
12107
|
{
|
|
12108
12108
|
"path": "tests/goal_abuse_matrix.mjs",
|
|
@@ -14214,6 +14214,11 @@
|
|
|
14214
14214
|
"mode": "0000644",
|
|
14215
14215
|
"sha256": "caa611e8f29f1ac55e2c15d139e90f064b69b17f8b77517d888218f17ce23dc8"
|
|
14216
14216
|
},
|
|
14217
|
+
{
|
|
14218
|
+
"path": "tests/read_default_meter.mjs",
|
|
14219
|
+
"mode": "0000644",
|
|
14220
|
+
"sha256": "63f169b03605bc96a89107fdd1395672383d8e282141ddf02cc6dd076793f072"
|
|
14221
|
+
},
|
|
14217
14222
|
{
|
|
14218
14223
|
"path": "tests/read_session_export.mjs",
|
|
14219
14224
|
"mode": "0000644",
|
|
@@ -2579,5 +2579,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2579
2579
|
landed since 1.19.1044 with no explicit bump. run 36263855490. (task 1002620)
|
|
2580
2580
|
1.19.1046 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2581
2581
|
landed since 1.19.1045 with no explicit bump. run 36265788618. (task 1002620)
|
|
2582
|
+
1.19.1047 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2583
|
+
landed since 1.19.1046 with no explicit bump. run 36280220559. (task 1002620)
|
|
2584
|
+
1.19.1048 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2585
|
+
landed since 1.19.1047 with no explicit bump. run 36285856717. (task 1002620)
|
|
2582
2586
|
---------------------------------------------------------------------------
|
|
2583
2587
|
```
|
|
@@ -222,6 +222,25 @@ answerable without going and looking. Two different unknowns, two different verd
|
|
|
222
222
|
will not answer, and a broken read must never be a quieter way past it than the owner's own
|
|
223
223
|
override. The fix is in the instance's logs, not on anyone's review queue.
|
|
224
224
|
|
|
225
|
+
**What `--apply` does, in order** (task 1004306):
|
|
226
|
+
|
|
227
|
+
1. **Rehearse.** It runs the exact `bongos upgrade` it is about to run, plus `--dry-run`. That
|
|
228
|
+
asks every refusal the bump has (the artist gate, module compatibility, the database
|
|
229
|
+
identity, the downgrade guard) and changes nothing. A refusal stops here: no timer stopped,
|
|
230
|
+
no backup written, and the report says "Nothing changed". Before this, the backup came
|
|
231
|
+
first, so on cloudbongos.com every attempt the artist gate refused had already written a
|
|
232
|
+
65 MB dump: one a day at the daily cadence, a full disk in about three days at every fifteen
|
|
233
|
+
minutes.
|
|
234
|
+
2. **Stop the pull-deploy timer and back up** to `backupDir` as
|
|
235
|
+
`pre-<version>-<timestamp>.sql.gz`. A dump that fails now fails the go-live (before, a failed
|
|
236
|
+
`pg_dump` still left a tiny gzip that read as a backup) and the timer is put back.
|
|
237
|
+
3. **Prune:** keep the newest **10** of those pre-upgrade dumps, by time, and delete the rest.
|
|
238
|
+
Only that exact name pattern is touched: a dump named by hand (`pre-lane-sweep-…`) and the
|
|
239
|
+
nightly backups (`db-backup-nightly.sh`, `<db>-<timestamp>.sql.gz`, kept 30 days by that
|
|
240
|
+
script) are never pruned here. A
|
|
241
|
+
prune that cannot run is a ⚠ warning, not a failed deploy.
|
|
242
|
+
4. **Bump, restart, read back**, as before.
|
|
243
|
+
|
|
225
244
|
Host topology (ssh alias, box paths, service names, health URL) lives in a host-owned
|
|
226
245
|
`config/go-live.json`, never in the core — run `node scripts/gds/go-live.js --help` for the
|
|
227
246
|
schema. A core that named one instance's box would not be a core.
|
|
@@ -33,7 +33,7 @@ function createReadRateLimit({ scope, limit, windowMs = 60 * 1000, maxTrackedIps
|
|
|
33
33
|
}, SWEEP_EVERY_MS);
|
|
34
34
|
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const limiter = function readRateLimit(req, res, next) {
|
|
37
37
|
const now = Date.now();
|
|
38
38
|
const key = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
39
39
|
let bucket = buckets.get(key);
|
|
@@ -57,6 +57,10 @@ function createReadRateLimit({ scope, limit, windowMs = 60 * 1000, maxTrackedIps
|
|
|
57
57
|
bucket.push(now);
|
|
58
58
|
return next();
|
|
59
59
|
};
|
|
60
|
+
// Self-metered tag (task 1003883): the core's default read ceiling skips a route carrying
|
|
61
|
+
// one, so this surface is not also counted in a second, looser bucket. See rate-limit.js.
|
|
62
|
+
limiter.meteredScope = scope;
|
|
63
|
+
return limiter;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
module.exports = { createReadRateLimit, MAX_TRACKED_IPS };
|
|
@@ -56,6 +56,10 @@ function profileReadRateLimit(req, res, next) {
|
|
|
56
56
|
return next();
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// Self-metered tag (task 1003883): the core's default read ceiling skips a route that
|
|
60
|
+
// carries one, so this surface is not also counted in a second bucket. See rate-limit.js.
|
|
61
|
+
profileReadRateLimit.meteredScope = 'public-profile-read';
|
|
62
|
+
|
|
59
63
|
module.exports = function publicProfileRoutes() {
|
|
60
64
|
const router = express.Router();
|
|
61
65
|
|
|
@@ -26,7 +26,7 @@ function clientIp(req) {
|
|
|
26
26
|
|
|
27
27
|
function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now, key = clientIp } = {}) {
|
|
28
28
|
const buckets = new Map(); // key -> array of recent hit timestamps within the window
|
|
29
|
-
|
|
29
|
+
const limiter = function askRateLimit(req, res, next) {
|
|
30
30
|
const t = now();
|
|
31
31
|
const ip = key(req) || 'unknown';
|
|
32
32
|
// Prune this caller's bucket to the window, then decide.
|
|
@@ -40,6 +40,12 @@ function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now, key =
|
|
|
40
40
|
buckets.set(ip, hits);
|
|
41
41
|
return next();
|
|
42
42
|
};
|
|
43
|
+
// Tag it as a self-metered route (task 1003883): the core's default read ceiling skips
|
|
44
|
+
// any route carrying a `meteredScope`, so a route behind THIS limiter is not counted a
|
|
45
|
+
// second time in a looser bucket. Plain property, no import — a module reaches core only
|
|
46
|
+
// through the doorway, and the convention is the contract (see rate-limit.js).
|
|
47
|
+
limiter.meteredScope = 'provisioning-ask';
|
|
48
|
+
return limiter;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
module.exports = { makeAskRateLimiter, clientIp };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1048",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1048",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1048",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7577,5 +7577,17 @@
|
|
|
7577
7577
|
"id": "1003444",
|
|
7578
7578
|
"text": "The hall's People page is gone, as you asked. An old People link now opens the Builders page instead, and People is no longer in the side menu. Searching for builders across projects comes back when the hub's community page is"
|
|
7579
7579
|
}
|
|
7580
|
+
],
|
|
7581
|
+
"1.19.1047": [
|
|
7582
|
+
{
|
|
7583
|
+
"id": "1004306",
|
|
7584
|
+
"text": "An update that would be refused no longer takes a database backup first, and the server keeps only the ten newest pre-update backups, so frequent update checks can't fill the disk."
|
|
7585
|
+
}
|
|
7586
|
+
],
|
|
7587
|
+
"1.19.1048": [
|
|
7588
|
+
{
|
|
7589
|
+
"id": "1003883",
|
|
7590
|
+
"text": "Public read endpoints are now rate-limited by default, so a newly added endpoint can no longer go live unprotected; existing protected endpoints keep their own limits."
|
|
7591
|
+
}
|
|
7580
7592
|
]
|
|
7581
7593
|
}
|
package/scripts/gds/go-live.js
CHANGED
|
@@ -571,7 +571,23 @@ function renderReport(r) {
|
|
|
571
571
|
L.push(` Go-live · ${r.targetName}`);
|
|
572
572
|
L.push(BAR);
|
|
573
573
|
L.push(` ${r.from} → ${r.to}`);
|
|
574
|
-
for (const step of r.steps || []) L.push(` ${step.ok ? '✓' : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
|
|
574
|
+
for (const step of r.steps || []) L.push(` ${step.ok ? (step.warn ? '⚠' : '✓') : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
|
|
575
|
+
|
|
576
|
+
// A refused rehearsal moved nothing, so the pin and read-back lines below would only
|
|
577
|
+
// alarm: "pin committed NO — the next reset will revert this bump" about a bump that
|
|
578
|
+
// never started (task 1004306).
|
|
579
|
+
if (r.failedAt === 'PREFLIGHT') {
|
|
580
|
+
const ex = failureExcerpt(r.raw);
|
|
581
|
+
if (ex.length) {
|
|
582
|
+
L.push('');
|
|
583
|
+
L.push(' what the upgrade said:');
|
|
584
|
+
for (const l of ex) L.push(` ${l}`);
|
|
585
|
+
}
|
|
586
|
+
L.push('');
|
|
587
|
+
L.push(` Nothing changed — still on ${r.from || 'the current core'}. No timer was stopped and no backup was written.`);
|
|
588
|
+
L.push('');
|
|
589
|
+
return L.join('\n');
|
|
590
|
+
}
|
|
575
591
|
|
|
576
592
|
if (!r.ok) {
|
|
577
593
|
const ex = failureExcerpt(r.raw);
|
|
@@ -681,24 +697,96 @@ function pinStep(mode, pinDirty, head) {
|
|
|
681
697
|
};
|
|
682
698
|
}
|
|
683
699
|
|
|
700
|
+
// How many pre-upgrade dumps a backupDir keeps (task 1004306). Nothing pruned them, so
|
|
701
|
+
// the directory grew by one dump per attempt for as long as the lane ran: 37 dumps and
|
|
702
|
+
// 1.7 GB on cloudbongos.com by 2026-09-26, at about ONE attempt a day. The count, not an age,
|
|
703
|
+
// is the bound because the cadence is what varies — ten is ten at daily or at every
|
|
704
|
+
// fifteen minutes. These are the extra copies taken right before a pin move; the
|
|
705
|
+
// nightly backup (db-backup-nightly.sh, 30 days) is the history and is not touched.
|
|
706
|
+
const PRE_UPGRADE_KEEP = 10;
|
|
707
|
+
// Exactly the name Phase A writes: pre-<version>-<YYYYMMDDTHHMMSSZ>.sql.gz. A looser
|
|
708
|
+
// `pre-*` would also take a dump an operator named by hand — the box has a
|
|
709
|
+
// `pre-lane-sweep-<ts>.sql.gz` in that directory, and it is not ours to delete.
|
|
710
|
+
const D8 = '[0-9]'.repeat(8);
|
|
711
|
+
const D6 = '[0-9]'.repeat(6);
|
|
712
|
+
const PRE_UPGRADE_GLOB = `pre-[0-9]*-${D8}T${D6}Z.sql.gz`;
|
|
713
|
+
|
|
714
|
+
// Delete all but the newest PRE_UPGRADE_KEEP pre-upgrade dumps and print how many went.
|
|
715
|
+
// Newest by mtime, never by name: the name sorts 1.19.99 after 1.19.100. It runs only
|
|
716
|
+
// after a dump succeeded, so a run of failures can never empty the directory, and the
|
|
717
|
+
// dump just written is always the newest and always kept. No `$` anywhere, so the
|
|
718
|
+
// outer shell that feeds this to `bash -c` expands nothing in it.
|
|
719
|
+
function pruneCommand(dir, keep = PRE_UPGRADE_KEEP) {
|
|
720
|
+
return `bash -c "cd ${dir} && ls -1t -- ${PRE_UPGRADE_GLOB} 2>/dev/null | tail -n +${keep + 1} | xargs -r rm -v -- | wc -l"`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Read the prune section back into a step. A prune that could not run is a WARNING,
|
|
724
|
+
// not a failure: the dump it follows is good, and refusing the upgrade over a full
|
|
725
|
+
// cleanup would trade a real deploy for tidiness. It still says so on screen.
|
|
726
|
+
function pruneStep(section, keep = PRE_UPGRADE_KEEP) {
|
|
727
|
+
const name = `prune old pre-upgrade backups (keep the newest ${keep})`;
|
|
728
|
+
const s = String(section == null ? '' : section).trim();
|
|
729
|
+
if (/^\d+$/.test(s)) return { name, ok: true, detail: s === '0' ? 'nothing to remove' : `removed ${s}` };
|
|
730
|
+
return { name, ok: true, warn: true, detail: `could not prune${s ? ` (${s.split('\n').pop()})` : ''} — the directory will keep growing until this is fixed` };
|
|
731
|
+
}
|
|
732
|
+
|
|
684
733
|
function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
|
|
685
734
|
const pkg = target.registryPackage || '@bongos/core';
|
|
686
735
|
const pinMode = pinModeOf(target);
|
|
687
736
|
const steps = [];
|
|
737
|
+
const upgrade = upgradeCommand(target, targetVersion);
|
|
738
|
+
|
|
739
|
+
// Phase 0 — ask the upgrade whether it WILL go, before anything is stopped or copied
|
|
740
|
+
// (task 1004306). `bongos upgrade --dry-run` runs every refusal the real bump has —
|
|
741
|
+
// the artist gate, module compatibility, the database identity, the downgrade guard —
|
|
742
|
+
// and returns before it changes anything. Phase A used to come first, so on
|
|
743
|
+
// cloudbongos.com (2026-09-26 17:10 and 17:15 UTC) each attempt the artist gate
|
|
744
|
+
// refused had already stopped the deploy timer and written a 65 MB dump: one a day at
|
|
745
|
+
// the daily cadence, a full disk in about three days at every fifteen minutes.
|
|
746
|
+
// runGoLive's own gate read did not stop those runs either, and left no trace of what
|
|
747
|
+
// it saw: on --apply a plan with no halts is never printed, notes included. The bump's
|
|
748
|
+
// pre-flight asks the instance directly, and it was the first thing to say no.
|
|
749
|
+
const rehearsal = remote(target, `
|
|
750
|
+
set -uo pipefail
|
|
751
|
+
${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade} --dry-run"`)}
|
|
752
|
+
`, opts);
|
|
753
|
+
steps.push({
|
|
754
|
+
name: 'pre-flight (the upgrade, rehearsed with --dry-run)',
|
|
755
|
+
ok: rehearsal.ok,
|
|
756
|
+
detail: rehearsal.ok ? null : 'refused — nothing was stopped and nothing was backed up',
|
|
757
|
+
});
|
|
758
|
+
if (!rehearsal.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREFLIGHT', refused: true, raw: rehearsal };
|
|
688
759
|
|
|
689
760
|
// Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
|
|
690
761
|
// is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
|
|
691
762
|
// such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
|
|
763
|
+
//
|
|
764
|
+
// The dump must FAIL when pg_dump fails. `pg_dump | gzip > f` without pipefail
|
|
765
|
+
// reports gzip's success, leaving a 20-byte file that reads as a backup — harmless
|
|
766
|
+
// while nothing pruned, but the prune below keeps the newest ten, so a streak of
|
|
767
|
+
// those would retire every real dump (task 1004306). A failed dump removes its file
|
|
768
|
+
// and fails the phase, which stops the go-live before the pin moves: a bump with no
|
|
769
|
+
// backup behind it is not one to make unattended. $TS is expanded once by this
|
|
770
|
+
// script so the dump and its cleanup name the same file.
|
|
771
|
+
const backupDir = target.backupDir || '~/backups';
|
|
772
|
+
const dumpFile = `${backupDir}/pre-${targetVersion}-$TS.sql.gz`;
|
|
692
773
|
const backup = target.backupDb
|
|
693
|
-
? asOwner(target, `bash -c "mkdir -p ${
|
|
774
|
+
? asOwner(target, `bash -c "set -o pipefail; mkdir -p ${backupDir} && { pg_dump ${target.backupDb} | gzip > ${dumpFile} || { rm -f ${dumpFile}; exit 1; }; }"`)
|
|
694
775
|
: 'echo "(no backupDb configured — skipping)"';
|
|
776
|
+
// `|| echo`: under `set -e` a failed prune would otherwise fail the whole phase and
|
|
777
|
+
// stop a deploy whose backup is already safely on disk (see pruneStep).
|
|
778
|
+
const prune = target.backupDb
|
|
779
|
+
? `echo "##PRUNE##"; ${asOwner(target, pruneCommand(backupDir))} || echo "prune exited non-zero"`
|
|
780
|
+
: '';
|
|
695
781
|
const phaseA = remote(target, `
|
|
696
782
|
set -euo pipefail
|
|
697
783
|
${SUDO_PREAMBLE}
|
|
698
784
|
echo "##TIMER##"; ${target.deployTimer
|
|
699
785
|
? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
|
|
700
786
|
: 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
|
|
787
|
+
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
701
788
|
echo "##BACKUP##"; ${backup}
|
|
789
|
+
${prune}
|
|
702
790
|
echo "##END##"
|
|
703
791
|
`, opts);
|
|
704
792
|
steps.push({
|
|
@@ -706,11 +794,19 @@ echo "##END##"
|
|
|
706
794
|
ok: phaseA.ok,
|
|
707
795
|
detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
|
|
708
796
|
});
|
|
709
|
-
if (!phaseA.ok)
|
|
797
|
+
if (!phaseA.ok) {
|
|
798
|
+
// The timer may already be stopped — Phase A stops it before the dump, and a dump
|
|
799
|
+
// can now fail on its own (above). Put it back, as a failed upgrade does below.
|
|
800
|
+
if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
|
|
801
|
+
return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
|
|
802
|
+
}
|
|
803
|
+
if (target.backupDb) {
|
|
804
|
+
const m = (phaseA.stdout || '').split('##PRUNE##')[1];
|
|
805
|
+
steps.push(pruneStep(m === undefined ? null : m.split('##')[0]));
|
|
806
|
+
}
|
|
710
807
|
|
|
711
808
|
// Phase B — the upgrade itself. --commit-pin makes the bump durable against the
|
|
712
809
|
// pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
|
|
713
|
-
const upgrade = upgradeCommand(target, targetVersion);
|
|
714
810
|
const phaseB = remote(target, `
|
|
715
811
|
set -uo pipefail
|
|
716
812
|
${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`)}
|
|
@@ -970,6 +1066,6 @@ module.exports = {
|
|
|
970
1066
|
loadConfig, selectTarget, validateTarget, planGoLive, artistGateEntries, readArtistGate, ARTIST_GATE_PATH, ARTIST_GATE_LEVELS,
|
|
971
1067
|
parseMarker, parseMarkerHits,
|
|
972
1068
|
renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
|
|
973
|
-
applyGoLive, upgradeCommand, pinStep, pinModeOf,
|
|
1069
|
+
applyGoLive, upgradeCommand, pinStep, pinModeOf, pruneCommand, pruneStep, PRE_UPGRADE_KEEP, PRE_UPGRADE_GLOB,
|
|
974
1070
|
CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
|
|
975
1071
|
};
|
|
@@ -70,6 +70,22 @@
|
|
|
70
70
|
// route's Cache-Control) while capping a
|
|
71
71
|
// cache-ignoring scraper at ~2 req/s.
|
|
72
72
|
//
|
|
73
|
+
// 6. DEFAULT read limiter — 600 reads / 60s per IP on EVERY GET/HEAD whose route
|
|
74
|
+
// does not carry its own meter (task 1003883, owner
|
|
75
|
+
// decision 2026-09-13: metered is the default, a route
|
|
76
|
+
// opts OUT). #3-#5 and the provisioning pair were each
|
|
77
|
+
// mounted by hand after a red-team finding (tasks 1035,
|
|
78
|
+
// 1003339, 1002327, 1945), which is fail-open: every new
|
|
79
|
+
// public read was unmetered until a reviewer remembered.
|
|
80
|
+
// This inverts it. It is a CEILING, deliberately far
|
|
81
|
+
// above the per-surface budgets, so it never tightens an
|
|
82
|
+
// honest client and never becomes a second, looser bucket
|
|
83
|
+
// on a surface that already has its own: a route that
|
|
84
|
+
// carries a `meteredScope` tag (every per-surface limiter
|
|
85
|
+
// wears one) or `readLimitOptOut(reason)` is NOT counted
|
|
86
|
+
// here (the ADR 0209 double-rate trap). See the block
|
|
87
|
+
// above module.exports.
|
|
88
|
+
//
|
|
73
89
|
// Any limiter tripping → 429 with Retry-After (seconds rounded up to the
|
|
74
90
|
// nearest whole second). Audit middleware still fires on a write 429 — the row
|
|
75
91
|
// in audit_log is forensic value for "who tried too hard." (Reads are never
|
|
@@ -96,6 +112,13 @@ const ACCOUNT_EXISTENCE_WINDOW_MS = 60 * 1000;
|
|
|
96
112
|
const ACCOUNT_EXISTENCE_LIMIT = 120;
|
|
97
113
|
const PROJECT_FEED_WINDOW_MS = 60 * 1000;
|
|
98
114
|
const PROJECT_FEED_LIMIT = 120;
|
|
115
|
+
const DEFAULT_READ_WINDOW_MS = 60 * 1000;
|
|
116
|
+
const DEFAULT_READ_LIMIT = 600;
|
|
117
|
+
// Cap on tracked IPs for the default ceiling — it sees the whole read surface, so an
|
|
118
|
+
// address-rotating caller must not be able to grow the map without bound between sweeps
|
|
119
|
+
// (same bound as modules/platform-identity/read-rate-limit.js).
|
|
120
|
+
const DEFAULT_READ_MAX_TRACKED_IPS = 10000;
|
|
121
|
+
const OPT_OUT_REASON_MIN_LENGTH = 20;
|
|
99
122
|
const SWEEP_MS = 5 * 60 * 1000;
|
|
100
123
|
|
|
101
124
|
const ipBuckets = new Map();
|
|
@@ -106,6 +129,8 @@ const publicReadBuckets = new Map();
|
|
|
106
129
|
const accountExistenceBuckets = new Map();
|
|
107
130
|
// Limiter #5's bucket map — the public projects feed (task 1002327, ADR 0252 §5.3).
|
|
108
131
|
const projectFeedBuckets = new Map();
|
|
132
|
+
// Limiter #6's bucket map — the default read ceiling (task 1003883).
|
|
133
|
+
const defaultReadBuckets = new Map();
|
|
109
134
|
|
|
110
135
|
const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
|
|
111
136
|
// HEAD is metered too: Express runs the GET handler (incl. the DB query) for a
|
|
@@ -175,6 +200,10 @@ function sweep() {
|
|
|
175
200
|
pruneAndCount(arr, now, PROJECT_FEED_WINDOW_MS);
|
|
176
201
|
if (arr.length === 0) projectFeedBuckets.delete(k);
|
|
177
202
|
}
|
|
203
|
+
for (const [k, arr] of defaultReadBuckets) {
|
|
204
|
+
pruneAndCount(arr, now, DEFAULT_READ_WINDOW_MS);
|
|
205
|
+
if (arr.length === 0) defaultReadBuckets.delete(k);
|
|
206
|
+
}
|
|
178
207
|
}
|
|
179
208
|
|
|
180
209
|
// Schedule the sweep. unref() so it doesn't keep the process alive during
|
|
@@ -258,12 +287,18 @@ function publicReadRateLimitMiddleware(req, res, next) {
|
|
|
258
287
|
// names that budget in the 429 body + headers so a caller can tell which ceiling
|
|
259
288
|
// it hit. Keyed on req.ip — the same real-client-IP resolution the write limiter
|
|
260
289
|
// uses (Caddy forwards the client IP, server sets 'trust proxy').
|
|
261
|
-
function perIpSlidingWindow(buckets, { limit, windowMs, scope }) {
|
|
262
|
-
return function slidingWindowRateLimit(req, res, next) {
|
|
290
|
+
function perIpSlidingWindow(buckets, { limit, windowMs, scope, maxTracked = Infinity }) {
|
|
291
|
+
return markMetered(function slidingWindowRateLimit(req, res, next) {
|
|
263
292
|
const now = Date.now();
|
|
264
293
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
265
294
|
let bucket = buckets.get(ip);
|
|
266
|
-
if (!bucket) {
|
|
295
|
+
if (!bucket) {
|
|
296
|
+
// Bound the map: a caller rotating source addresses (an IPv6 /64) mints a fresh
|
|
297
|
+
// bucket per request. Evict the longest-tracked entry (Map iterates oldest first).
|
|
298
|
+
if (buckets.size >= maxTracked) buckets.delete(buckets.keys().next().value);
|
|
299
|
+
bucket = [];
|
|
300
|
+
buckets.set(ip, bucket);
|
|
301
|
+
}
|
|
267
302
|
if (pruneAndCount(bucket, now, windowMs) >= limit) {
|
|
268
303
|
const retryAfter = Math.max(1, Math.ceil((bucket[0] + windowMs - now) / 1000));
|
|
269
304
|
res.set('Retry-After', String(retryAfter));
|
|
@@ -274,7 +309,7 @@ function perIpSlidingWindow(buckets, { limit, windowMs, scope }) {
|
|
|
274
309
|
}
|
|
275
310
|
bucket.push(now);
|
|
276
311
|
return next();
|
|
277
|
-
};
|
|
312
|
+
}, scope);
|
|
278
313
|
}
|
|
279
314
|
|
|
280
315
|
// accountExistenceReadRateLimit — limiter #4 in the header. ONE instance, mounted
|
|
@@ -299,11 +334,123 @@ const publicProjectFeedRateLimit = perIpSlidingWindow(projectFeedBuckets, {
|
|
|
299
334
|
scope: 'public-project-feed',
|
|
300
335
|
});
|
|
301
336
|
|
|
337
|
+
// ── the default read ceiling (limiter #6, task 1003883) ─────────────────────────
|
|
338
|
+
//
|
|
339
|
+
// HOW A ROUTE OPTS OUT. A read route is exempt from the default ceiling when one of the
|
|
340
|
+
// handlers on it carries a `meteredScope` string — the tag every per-surface limiter above
|
|
341
|
+
// wears (`perIpSlidingWindow` and `publicReadRateLimitMiddleware` set it), and which
|
|
342
|
+
// `readLimitOptOut(reason)` puts on a route that needs no meter at all. Grep `meteredScope`
|
|
343
|
+
// or `readLimitOptOut` to list them. The tag is what makes the exemption impossible to
|
|
344
|
+
// forget in the OTHER direction: a surface that has its own budget IS the one that is not
|
|
345
|
+
// counted here, so it can never end up with a second, looser bucket (the ADR 0209 trap).
|
|
346
|
+
//
|
|
347
|
+
// WHY A WALK OF THE ROUTER, NOT A TABLE. A table of exempt paths in this file would have to
|
|
348
|
+
// name the modules that own them (the kernel must stay module-agnostic) and would drift
|
|
349
|
+
// from the route files. The tag lives on the route, so the route file is the declaration.
|
|
350
|
+
// The default limiter is mounted FIRST, before any route exists to inspect, so it reads the
|
|
351
|
+
// composed router lazily on the first read and asks Express's own layer matching whether
|
|
352
|
+
// the request lands on a tagged route.
|
|
353
|
+
//
|
|
354
|
+
// EXPRESS 4 DEPENDENCY. This leans on Layer#match, Route#_handles_method and Layer#path /
|
|
355
|
+
// #regexp, which are Express 4 internals. tests/read_default_meter.mjs asserts the major
|
|
356
|
+
// version and the production exempt set, so an upgrade fails there, loudly, instead of
|
|
357
|
+
// quietly changing which reads are metered. Layer#match writes layer.path/params; that is
|
|
358
|
+
// safe only because the walk is synchronous and Express re-matches each layer itself when
|
|
359
|
+
// it dispatches. The exempt list is read once, on the first read: a router mounted after
|
|
360
|
+
// traffic starts is not seen (every mount happens in buildGdsRouter, before listen).
|
|
361
|
+
function markMetered(fn, scope) {
|
|
362
|
+
fn.meteredScope = scope;
|
|
363
|
+
return fn;
|
|
364
|
+
}
|
|
365
|
+
markMetered(publicReadRateLimitMiddleware, 'public-read');
|
|
366
|
+
|
|
367
|
+
// readLimitOptOut(reason) — an explicit, visible opt-out for a read route that must not be
|
|
368
|
+
// metered by the default (a liveness probe). `router.get('/healthz', readLimitOptOut('…'), h)`.
|
|
369
|
+
function readLimitOptOut(reason) {
|
|
370
|
+
if (typeof reason !== 'string' || reason.trim().length < OPT_OUT_REASON_MIN_LENGTH) {
|
|
371
|
+
throw new Error(`readLimitOptOut needs a reason of at least ${OPT_OUT_REASON_MIN_LENGTH} characters saying why this read is not metered`);
|
|
372
|
+
}
|
|
373
|
+
return markMetered(function readLimitOptOutMarker(_req, _res, next) { next(); }, 'opt-out');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Every route (and mount) on the router that carries a `meteredScope` handler, as chains of
|
|
377
|
+
// the Express layers to match in order. Nested routers extend the chain; a tagged
|
|
378
|
+
// `router.use(prefix, limiter)` ends it on a prefix layer.
|
|
379
|
+
function collectSelfMetered(stack, chain = [], out = [], seen = new Set()) {
|
|
380
|
+
if (!Array.isArray(stack) || seen.has(stack)) return out;
|
|
381
|
+
seen.add(stack);
|
|
382
|
+
for (const layer of stack) {
|
|
383
|
+
if (layer.route && Array.isArray(layer.route.stack)) {
|
|
384
|
+
const tag = layer.route.stack.map((l) => l.handle && l.handle.meteredScope).find(Boolean);
|
|
385
|
+
if (tag) out.push({ scope: tag, chain: [...chain, layer], route: layer.route });
|
|
386
|
+
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
|
|
387
|
+
collectSelfMetered(layer.handle.stack, [...chain, layer], out, seen);
|
|
388
|
+
} else if (layer.handle && layer.handle.meteredScope) {
|
|
389
|
+
// A limiter mounted with NO prefix (`router.use(limiter)`) would match every path and
|
|
390
|
+
// silently exempt the whole API from the ceiling — a fail-open. Only a prefixed mount
|
|
391
|
+
// can exempt; an unprefixed one is ignored, so those reads stay on the ceiling.
|
|
392
|
+
if (layer.regexp && layer.regexp.fast_slash) continue;
|
|
393
|
+
out.push({ scope: layer.handle.meteredScope, chain: [...chain, layer], route: null });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Does the path land on an entry? Mirrors what Express does when it dispatches: a mount
|
|
400
|
+
// layer strips its matched prefix before the next layer sees the rest.
|
|
401
|
+
function entryMatches(entry, method, p) {
|
|
402
|
+
let rest = p;
|
|
403
|
+
for (const layer of entry.chain) {
|
|
404
|
+
if (!layer.match(rest)) return false;
|
|
405
|
+
if (layer.route) return layer.route._handles_method(method.toLowerCase());
|
|
406
|
+
rest = rest.slice(String(layer.path || '').length) || '/';
|
|
407
|
+
}
|
|
408
|
+
// Ended on a prefix layer holding a tagged limiter: the prefix matching is the answer.
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// selfMeteredRoutes(router) — for tests and diagnostics: [{ scope, path }] of the reads that
|
|
413
|
+
// are NOT on the default ceiling. `path` is the route's own (mount-relative) pattern.
|
|
414
|
+
function selfMeteredRoutes(router) {
|
|
415
|
+
return collectSelfMetered(router.stack).map((e) => ({
|
|
416
|
+
scope: e.scope,
|
|
417
|
+
path: e.route ? e.route.path : String(e.chain[e.chain.length - 1].regexp),
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// defaultReadRateLimit(router) — limiter #6. Mount it ONCE, first, on the router it guards:
|
|
422
|
+
// `router.use(defaultReadRateLimit(router))`. Keyed on req.ip only: keying on a bearer token
|
|
423
|
+
// would let an unauthenticated caller escape the ceiling by attaching any Authorization
|
|
424
|
+
// header.
|
|
425
|
+
function defaultReadRateLimit(router) {
|
|
426
|
+
const meter = perIpSlidingWindow(defaultReadBuckets, {
|
|
427
|
+
limit: DEFAULT_READ_LIMIT,
|
|
428
|
+
windowMs: DEFAULT_READ_WINDOW_MS,
|
|
429
|
+
scope: 'default-read',
|
|
430
|
+
maxTracked: DEFAULT_READ_MAX_TRACKED_IPS,
|
|
431
|
+
});
|
|
432
|
+
let entries = null;
|
|
433
|
+
return function defaultReadRateLimitMiddleware(req, res, next) {
|
|
434
|
+
if (!READ_METHODS.has(req.method)) return next();
|
|
435
|
+
// Lazy: the router is still being composed when this is mounted. By the first request
|
|
436
|
+
// it is complete (and every module router has been mounted).
|
|
437
|
+
if (entries === null) entries = collectSelfMetered(router.stack);
|
|
438
|
+
for (const e of entries) if (entryMatches(e, req.method, req.path)) return next();
|
|
439
|
+
return meter(req, res, next);
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
302
443
|
module.exports = {
|
|
303
444
|
rateLimitMiddleware,
|
|
304
445
|
publicReadRateLimitMiddleware,
|
|
305
446
|
accountExistenceReadRateLimit,
|
|
306
447
|
publicProjectFeedRateLimit,
|
|
448
|
+
defaultReadRateLimit,
|
|
449
|
+
readLimitOptOut,
|
|
450
|
+
markMetered,
|
|
451
|
+
selfMeteredRoutes,
|
|
452
|
+
DEFAULT_READ_LIMIT,
|
|
453
|
+
DEFAULT_READ_MAX_TRACKED_IPS,
|
|
307
454
|
// exported for tests + the security-baseline-check script
|
|
308
455
|
PER_IP_WINDOW_MS,
|
|
309
456
|
PER_IP_LIMIT,
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
const express = require('express');
|
|
12
12
|
const auth = require('../auth');
|
|
13
|
+
const { readLimitOptOut } = require('../middleware/rate-limit');
|
|
13
14
|
|
|
14
15
|
module.exports = function buildHealthzRouter() {
|
|
15
16
|
const router = express.Router();
|
|
16
17
|
|
|
17
18
|
// rank: public — liveness probe; the status dashboard (status.<apex>) hits this anonymously.
|
|
18
|
-
router.get('/healthz', async (_req, res) => {
|
|
19
|
+
router.get('/healthz', readLimitOptOut('a liveness probe must answer 200 while everything else is being throttled'), async (_req, res) => {
|
|
19
20
|
res.json({
|
|
20
21
|
ok: true,
|
|
21
22
|
auth_configured: auth.authConfigured(),
|
package/src/bongos/routes.js
CHANGED
|
@@ -68,7 +68,7 @@ const buildLlmCacheRouter = require('./routes/llm-cache');
|
|
|
68
68
|
const { isModuleEnabled } = require('../modules');
|
|
69
69
|
const moduleLoader = require('../module-loader/loader');
|
|
70
70
|
const { auditMiddleware } = require('./middleware/audit');
|
|
71
|
-
const { rateLimitMiddleware } = require('./middleware/rate-limit');
|
|
71
|
+
const { rateLimitMiddleware, defaultReadRateLimit } = require('./middleware/rate-limit');
|
|
72
72
|
const { attachFail, errorEnvelopeHandler } = require('./middleware/error-envelope');
|
|
73
73
|
const { hardenRouterStack } = require('./routes/_helpers');
|
|
74
74
|
|
|
@@ -128,9 +128,13 @@ function buildGdsRouter() {
|
|
|
128
128
|
router.use(auditMiddleware());
|
|
129
129
|
|
|
130
130
|
// Rate-limit middleware (V3.R34 #255) — sliding-window in-memory limiter.
|
|
131
|
-
// 30 writes/min per IP, 60 writes/min per session.
|
|
131
|
+
// 30 writes/min per IP, 60 writes/min per session.
|
|
132
132
|
// 429 + Retry-After + X-RateLimit-* headers on trip.
|
|
133
133
|
router.use(rateLimitMiddleware);
|
|
134
|
+
// Reads are METERED BY DEFAULT (task 1003883): a 600/min per-IP ceiling on every
|
|
135
|
+
// GET/HEAD unless the route carries its own meter (a `meteredScope` tag) or an
|
|
136
|
+
// explicit readLimitOptOut(reason) — see the default-read block in rate-limit.js.
|
|
137
|
+
router.use(defaultReadRateLimit(router));
|
|
134
138
|
|
|
135
139
|
router.use(buildHealthzRouter());
|
|
136
140
|
router.use(buildInstanceRouter());
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1048'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/go_live.mjs
CHANGED
|
@@ -595,15 +595,22 @@ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.1
|
|
|
595
595
|
'##END##',
|
|
596
596
|
].filter(Boolean).join('\n');
|
|
597
597
|
|
|
598
|
-
// Records every script fed to a shell. `upgradeFails` drives the rollback path
|
|
599
|
-
|
|
598
|
+
// Records every script fed to a shell. `upgradeFails` drives the rollback path;
|
|
599
|
+
// `rehearsalRefuses` makes the --dry-run pre-flight say no (task 1004306).
|
|
600
|
+
function fakeShell({ upgradeFails = false, rehearsalRefuses = false, backupFails = false, pruned = '0', phaseCOut = phaseC() } = {}) {
|
|
600
601
|
const scripts = [];
|
|
601
602
|
const fn = (cmd, args, opts = {}) => {
|
|
602
603
|
const input = opts.input || '';
|
|
603
604
|
scripts.push(input);
|
|
605
|
+
if (backupFails && input.includes('##BACKUP##')) return { status: 1, stdout: '##TIMER##\n##BACKUP##\n', stderr: 'pg_dump: error: connection to server failed' };
|
|
606
|
+
if (input.includes('bongos upgrade') && input.includes('--dry-run')) {
|
|
607
|
+
return rehearsalRefuses
|
|
608
|
+
? { status: 1, stdout: '', stderr: "\n✖ 5 unresolved artist reviews hold this deploy — the project runs artist_gate 'strict'\n" }
|
|
609
|
+
: { status: 0, stdout: ' [dry-run] would pin', stderr: '' };
|
|
610
|
+
}
|
|
604
611
|
if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
|
|
605
612
|
if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
|
|
606
|
-
return { status: 0, stdout:
|
|
613
|
+
return { status: 0, stdout: `##TIMER##\n##BACKUP##\n${input.includes('##PRUNE##') ? `##PRUNE##\n${pruned}\n` : ''}##END##`, stderr: '' };
|
|
607
614
|
};
|
|
608
615
|
fn.scripts = scripts;
|
|
609
616
|
fn.all = () => scripts.join('\n');
|
|
@@ -660,7 +667,7 @@ test('with no deployTimer, nothing is stopped, started, or reported as restarted
|
|
|
660
667
|
assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
|
|
661
668
|
assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
|
|
662
669
|
assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
|
|
663
|
-
assert.match(stepNames(r)[
|
|
670
|
+
assert.match(stepNames(r)[1], /^backup \(no pull-deploy timer\)$/);
|
|
664
671
|
});
|
|
665
672
|
|
|
666
673
|
test('the platform instance still stops the timer and puts it back', () => {
|
|
@@ -728,3 +735,135 @@ test('the shipped co-tenant template is itself valid config', () => {
|
|
|
728
735
|
assert.equal(sel.target.pinMode, 'local-commit');
|
|
729
736
|
assert.equal(sel.target.deployTimer, undefined);
|
|
730
737
|
});
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Refuse before you copy; keep ten (task 1004306)
|
|
741
|
+
//
|
|
742
|
+
// On cloudbongos.com, 2026-09-26, the artist gate refused two upgrades at 17:10 and
|
|
743
|
+
// 17:15 UTC — and each had already stopped the deploy timer and written a 65 MB
|
|
744
|
+
// pg_dump, because the backup was Phase A and the refusal lived inside Phase B.
|
|
745
|
+
// Nothing pruned the directory (37 dumps, 1.7 GB, at about one attempt a day). At a
|
|
746
|
+
// fifteen-minute cadence that fills the disk in about three days.
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
const withBackup = { ...okTarget, backupDb: 'instdb', backupDir: '~/backups', runAs: 'me' };
|
|
750
|
+
|
|
751
|
+
test('a refused rehearsal stops the go-live before any timer is stopped or any dump is written', () => {
|
|
752
|
+
const run = fakeShell({ rehearsalRefuses: true });
|
|
753
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
754
|
+
|
|
755
|
+
assert.equal(r.ok, false);
|
|
756
|
+
assert.equal(r.failedAt, 'PREFLIGHT');
|
|
757
|
+
assert.equal(r.refused, true);
|
|
758
|
+
assert.equal(run.scripts.length, 1, 'the rehearsal is the only thing that may run');
|
|
759
|
+
assert.ok(!/pg_dump/.test(run.all()), 'no backup may be written for a refused move');
|
|
760
|
+
assert.ok(!/systemctl stop/.test(run.all()), 'the deploy timer must be left running');
|
|
761
|
+
assert.deepEqual(stepNames(r), ['pre-flight (the upgrade, rehearsed with --dry-run)']);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test('the rehearsal is the REAL upgrade command plus --dry-run, and it runs first', () => {
|
|
765
|
+
const run = fakeShell();
|
|
766
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
767
|
+
assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(r.steps)}`);
|
|
768
|
+
|
|
769
|
+
// Same flags as the bump, so it asks exactly the gates the bump will ask — a
|
|
770
|
+
// rehearsal of a different command would be a check of something else.
|
|
771
|
+
assert.ok(run.scripts[0].includes(`${G.upgradeCommand(withBackup, '1.19.306')} --dry-run`));
|
|
772
|
+
const order = (needle) => run.scripts.findIndex((s) => s.includes(needle));
|
|
773
|
+
assert.ok(order('--dry-run') < order('pg_dump'), 'rehearse before the backup');
|
|
774
|
+
assert.ok(order('--dry-run') < order('systemctl stop'), 'rehearse before the timer stops');
|
|
775
|
+
assert.ok(order('pg_dump') < run.scripts.findIndex((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'the backup still precedes the real bump');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('a refusal renders as "nothing changed", never as an unpushed pin', () => {
|
|
779
|
+
const run = fakeShell({ rehearsalRefuses: true });
|
|
780
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
781
|
+
const out = G.renderReport(r);
|
|
782
|
+
assert.match(out, /Nothing changed — still on 1\.19\.300\. No timer was stopped and no backup was written\./);
|
|
783
|
+
assert.match(out, /5 unresolved artist reviews hold this deploy/, "the upgrade's own reason is shown");
|
|
784
|
+
assert.ok(!/pin committed/.test(out), 'no pin line for a bump that never started');
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('a successful dump is followed by a prune, and the step says what it removed', () => {
|
|
788
|
+
const run = fakeShell({ pruned: '3' });
|
|
789
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
790
|
+
assert.equal(r.ok, true);
|
|
791
|
+
const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
|
|
792
|
+
assert.ok(phaseA.indexOf('##PRUNE##') > phaseA.indexOf('pg_dump'), 'prune only after the dump');
|
|
793
|
+
const step = r.steps.find((s) => /prune/.test(s.name));
|
|
794
|
+
assert.equal(step.name, `prune old pre-upgrade backups (keep the newest ${G.PRE_UPGRADE_KEEP})`);
|
|
795
|
+
assert.equal(step.detail, 'removed 3');
|
|
796
|
+
assert.equal(G.PRE_UPGRADE_KEEP, 10);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('no backupDb, no dump, and no prune', () => {
|
|
800
|
+
const run = fakeShell();
|
|
801
|
+
const r = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run });
|
|
802
|
+
assert.equal(r.ok, true);
|
|
803
|
+
assert.ok(!/##PRUNE##/.test(run.all()));
|
|
804
|
+
assert.ok(!r.steps.some((s) => /prune/.test(s.name)));
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('a prune that cannot run warns and does not fail the deploy', () => {
|
|
808
|
+
const step = G.pruneStep('prune exited non-zero');
|
|
809
|
+
assert.equal(step.ok, true, 'the dump is good; tidiness must not cost the deploy');
|
|
810
|
+
assert.equal(step.warn, true);
|
|
811
|
+
assert.match(step.detail, /could not prune \(prune exited non-zero\)/);
|
|
812
|
+
assert.equal(G.pruneStep(null).warn, true, 'a missing section is not "nothing to remove"');
|
|
813
|
+
assert.equal(G.pruneStep('0').detail, 'nothing to remove');
|
|
814
|
+
assert.match(G.renderReport({ targetName: 'x', from: 'a', to: 'b', ok: true, steps: [step] }), /⚠ prune old pre-upgrade backups/);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test('the dump fails when pg_dump fails, and a failed dump puts the stopped timer back', () => {
|
|
818
|
+
// The composed command: pipefail inside, and the partial file removed on failure.
|
|
819
|
+
const run = fakeShell();
|
|
820
|
+
G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run });
|
|
821
|
+
const phaseA = run.scripts.find((s) => s.includes('##BACKUP##'));
|
|
822
|
+
assert.ok(phaseA.includes('bash -c "set -o pipefail; mkdir -p ~/backups && { pg_dump instdb | gzip > ~/backups/pre-1.19.306-$TS.sql.gz || { rm -f ~/backups/pre-1.19.306-$TS.sql.gz; exit 1; }; }"'), phaseA);
|
|
823
|
+
assert.match(phaseA, /^TS="\$\(date -u \+%Y%m%dT%H%M%SZ\)"$/m, 'one timestamp for the dump and its cleanup');
|
|
824
|
+
|
|
825
|
+
const failing = fakeShell({ backupFails: true });
|
|
826
|
+
const r = G.applyGoLive(withBackup, 'web', '1.19.306', '1.19.300', { run: failing });
|
|
827
|
+
assert.equal(r.ok, false);
|
|
828
|
+
assert.equal(r.failedAt, 'PREPARE');
|
|
829
|
+
assert.ok(!failing.scripts.some((s) => s.includes('bongos upgrade') && !s.includes('--dry-run')), 'no bump without a backup');
|
|
830
|
+
assert.match(failing.scripts[failing.scripts.length - 1], /systemctl start inst-deploy\.timer/, 'the stopped timer is restarted');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
// The prune command itself, run for real in a scratch directory. The composed-string
|
|
834
|
+
// tests above cannot tell a glob that matches nothing from one that matches too much.
|
|
835
|
+
test('the prune keeps the newest ten by time, and leaves every other file alone', { skip: process.platform === 'win32' && 'needs a POSIX shell' }, async () => {
|
|
836
|
+
const { spawnSync } = await import('node:child_process');
|
|
837
|
+
const os = await import('node:os');
|
|
838
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'golive-prune-'));
|
|
839
|
+
try {
|
|
840
|
+
const dumps = [];
|
|
841
|
+
// 13 dumps, oldest first, whose NAME order disagrees with their TIME order.
|
|
842
|
+
for (let i = 0; i < 13; i++) {
|
|
843
|
+
const name = `pre-1.19.${i % 2 ? 99 - i : 100 + i}-202609${String(10 + i).padStart(2, '0')}T000000Z.sql.gz`;
|
|
844
|
+
const f = path.join(dir, name);
|
|
845
|
+
fs.writeFileSync(f, 'x');
|
|
846
|
+
const t = Date.UTC(2026, 8, 10 + i) / 1000;
|
|
847
|
+
fs.utimesSync(f, t, t);
|
|
848
|
+
dumps.push(name);
|
|
849
|
+
}
|
|
850
|
+
const others = ['pre-lane-sweep-20260823T203810Z.sql.gz', 'cloudbongos-2026-09-26T040000Z.sql.gz', 'notes.txt'];
|
|
851
|
+
for (const o of others) {
|
|
852
|
+
fs.writeFileSync(path.join(dir, o), 'x');
|
|
853
|
+
fs.utimesSync(path.join(dir, o), 1, 1); // older than every dump
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const res = spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' });
|
|
857
|
+
assert.equal(res.status, 0, res.stderr);
|
|
858
|
+
assert.equal(res.stdout.trim(), '3');
|
|
859
|
+
const left = new Set(fs.readdirSync(dir));
|
|
860
|
+
for (const d of dumps.slice(0, 3)) assert.ok(!left.has(d), `${d} is one of the three oldest and must go`);
|
|
861
|
+
for (const d of dumps.slice(3)) assert.ok(left.has(d), `${d} is one of the newest ten and must stay`);
|
|
862
|
+
for (const o of others) assert.ok(left.has(o), `${o} is not a pre-upgrade dump and must never be pruned`);
|
|
863
|
+
|
|
864
|
+
// A second run has nothing left to do.
|
|
865
|
+
assert.equal(spawnSync('sh', ['-c', G.pruneCommand(dir)], { encoding: 'utf8' }).stdout.trim(), '0');
|
|
866
|
+
} finally {
|
|
867
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
868
|
+
}
|
|
869
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/read_default_meter.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003883 — API reads are METERED BY DEFAULT; a route opts OUT explicitly.
|
|
4
|
+
//
|
|
5
|
+
// Before this, the two global limiters bypassed reads and each read surface was mounted
|
|
6
|
+
// by hand after a red-team finding (tasks 1035, 1003339, 1002327, 1945), so every NEW
|
|
7
|
+
// public read was unmetered until a reviewer remembered. The default limiter inverts
|
|
8
|
+
// that. A route opts out by carrying a `meteredScope` tag (every per-surface limiter
|
|
9
|
+
// wears one) or `readLimitOptOut(reason)`.
|
|
10
|
+
//
|
|
11
|
+
// Pins (against a REAL Express router, matched by Express's own layers):
|
|
12
|
+
// 1. A read on a route nobody thought about is metered at DEFAULT_READ_LIMIT / 60s per
|
|
13
|
+
// IP, with the 429 shape + X-RateLimit-Scope: default-read.
|
|
14
|
+
// 2. A route behind its own limiter is NOT counted here (no second bucket — the
|
|
15
|
+
// ADR 0209 trap), including one inside a nested router and one mounted by prefix.
|
|
16
|
+
// 3. Non-read methods bypass it; HEAD is metered like GET.
|
|
17
|
+
// 4. readLimitOptOut demands a real reason.
|
|
18
|
+
// 5. The composed production router: every read that is exempt is exempt on purpose —
|
|
19
|
+
// exactly the known per-surface set — so a new tagged route shows up as a diff here.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test tests/read_default_meter.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const express = require('express');
|
|
29
|
+
const {
|
|
30
|
+
defaultReadRateLimit,
|
|
31
|
+
readLimitOptOut,
|
|
32
|
+
markMetered,
|
|
33
|
+
selfMeteredRoutes,
|
|
34
|
+
publicReadRateLimitMiddleware,
|
|
35
|
+
accountExistenceReadRateLimit,
|
|
36
|
+
publicProjectFeedRateLimit,
|
|
37
|
+
DEFAULT_READ_LIMIT,
|
|
38
|
+
DEFAULT_READ_MAX_TRACKED_IPS,
|
|
39
|
+
} = require('../src/bongos/middleware/rate-limit.js');
|
|
40
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
41
|
+
|
|
42
|
+
function fakeRes() {
|
|
43
|
+
const headers = {};
|
|
44
|
+
const res = {
|
|
45
|
+
statusCode: 200, headers, body: undefined,
|
|
46
|
+
set(k, v) { headers[k.toLowerCase()] = String(v); return this; },
|
|
47
|
+
status(c) { this.statusCode = c; return this; },
|
|
48
|
+
json(o) { this.body = o; return this; },
|
|
49
|
+
};
|
|
50
|
+
attachFail({}, res, () => {});
|
|
51
|
+
return res;
|
|
52
|
+
}
|
|
53
|
+
function hit(mw, method, p, ip) {
|
|
54
|
+
const req = { method, path: p, ip, headers: {}, socket: { remoteAddress: ip } };
|
|
55
|
+
const res = fakeRes();
|
|
56
|
+
let nexted = false;
|
|
57
|
+
mw(req, res, () => { nexted = true; });
|
|
58
|
+
return { res, nexted };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A router shaped like the real one: unlisted routes, a self-metered route, a nested router
|
|
62
|
+
// mounted at the root (how modules mount), a prefix mount carrying a limiter (/public), and
|
|
63
|
+
// an explicit opt-out.
|
|
64
|
+
const ok = (_req, res) => res.json({});
|
|
65
|
+
function buildRouter() {
|
|
66
|
+
const router = express.Router();
|
|
67
|
+
const guard = defaultReadRateLimit(router);
|
|
68
|
+
router.use(guard);
|
|
69
|
+
router.get('/plain', ok);
|
|
70
|
+
router.get('/things/:id', ok);
|
|
71
|
+
router.post('/plain', ok);
|
|
72
|
+
router.get('/own-budget', accountExistenceReadRateLimit, ok);
|
|
73
|
+
router.get('/feed', publicProjectFeedRateLimit, ok);
|
|
74
|
+
router.get('/probe', readLimitOptOut('a liveness probe must answer while everything else is throttled'), ok);
|
|
75
|
+
const mod = express.Router();
|
|
76
|
+
mod.get('/from-module', markMetered((_q, _s, n) => n(), 'module-own'), ok);
|
|
77
|
+
mod.get('/module-plain', ok);
|
|
78
|
+
router.use(mod);
|
|
79
|
+
const pub = express.Router();
|
|
80
|
+
pub.use('/public', publicReadRateLimitMiddleware);
|
|
81
|
+
pub.get('/public/uptime', ok);
|
|
82
|
+
router.use(pub);
|
|
83
|
+
return { router, guard };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('a read on a route nobody thought about is metered: DEFAULT_READ_LIMIT pass, then 429', () => {
|
|
87
|
+
const { guard } = buildRouter();
|
|
88
|
+
const ip = '198.51.100.1';
|
|
89
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT; i++) {
|
|
90
|
+
assert.equal(hit(guard, 'GET', '/plain', ip).nexted, true, `read ${i + 1} passes`);
|
|
91
|
+
}
|
|
92
|
+
const { res, nexted } = hit(guard, 'GET', '/plain', ip);
|
|
93
|
+
assert.equal(nexted, false);
|
|
94
|
+
assert.equal(res.statusCode, 429);
|
|
95
|
+
assert.equal(res.headers['x-ratelimit-scope'], 'default-read');
|
|
96
|
+
assert.ok(Number(res.headers['retry-after']) >= 1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the ceiling is per IP and shared across every unlisted route, param routes included', () => {
|
|
100
|
+
const { guard } = buildRouter();
|
|
101
|
+
const a = '198.51.100.2';
|
|
102
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT; i++) hit(guard, 'GET', i % 2 ? '/plain' : `/things/${i}`, a);
|
|
103
|
+
assert.equal(hit(guard, 'GET', '/module-plain', a).nexted, false, 'a route in a nested router spends it too');
|
|
104
|
+
assert.equal(hit(guard, 'GET', '/things/9', '198.51.100.3').nexted, true, 'another IP is untouched');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('HEAD is metered like GET; a write is not this limiter\'s business', () => {
|
|
108
|
+
const { guard } = buildRouter();
|
|
109
|
+
const ip = '198.51.100.4';
|
|
110
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT; i++) hit(guard, 'HEAD', '/plain', ip);
|
|
111
|
+
assert.equal(hit(guard, 'GET', '/plain', ip).nexted, false, 'HEAD spent the budget');
|
|
112
|
+
for (let i = 0; i < 5; i++) assert.equal(hit(guard, 'POST', '/plain', ip).nexted, true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a route with its own meter is not counted here — nested, prefix-mounted and explicit alike', () => {
|
|
116
|
+
const { guard } = buildRouter();
|
|
117
|
+
const ip = '198.51.100.5';
|
|
118
|
+
const exempt = ['/own-budget', '/feed', '/probe', '/from-module', '/public/uptime', '/public/anything'];
|
|
119
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT + 50; i++) {
|
|
120
|
+
for (const p of exempt) assert.equal(hit(guard, 'GET', p, ip).nexted, true, `${p} is not on the ceiling`);
|
|
121
|
+
}
|
|
122
|
+
assert.equal(hit(guard, 'GET', '/plain', ip).nexted, true, 'and none of that spent the ceiling for everything else');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('an exhausted ceiling never blocks an opted-out route', () => {
|
|
126
|
+
const { guard } = buildRouter();
|
|
127
|
+
const ip = '198.51.100.6';
|
|
128
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT + 1; i++) hit(guard, 'GET', '/plain', ip);
|
|
129
|
+
assert.equal(hit(guard, 'GET', '/plain', ip).nexted, false);
|
|
130
|
+
assert.equal(hit(guard, 'GET', '/probe', ip).nexted, true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('prefix opt-outs respect segment boundaries', () => {
|
|
134
|
+
const { guard } = buildRouter();
|
|
135
|
+
const ip = '198.51.100.7';
|
|
136
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT + 1; i++) hit(guard, 'GET', '/plain', ip);
|
|
137
|
+
assert.equal(hit(guard, 'GET', '/public/x', ip).nexted, true);
|
|
138
|
+
assert.equal(hit(guard, 'GET', '/publicity', ip).nexted, false, '/publicity is not under /public');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the default ceiling bounds its own memory against address rotation', () => {
|
|
142
|
+
const { guard } = buildRouter();
|
|
143
|
+
const before = process.memoryUsage().heapUsed;
|
|
144
|
+
for (let i = 0; i < DEFAULT_READ_MAX_TRACKED_IPS + 500; i++) hit(guard, 'GET', '/plain', `2001:db8::${i.toString(16)}`);
|
|
145
|
+
// The oldest tracked address was evicted: it gets a fresh bucket rather than a stale one.
|
|
146
|
+
assert.equal(hit(guard, 'GET', '/plain', '2001:db8::0').nexted, true);
|
|
147
|
+
assert.ok(process.memoryUsage().heapUsed - before < 200 * 1024 * 1024, 'no runaway growth');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('a limiter mounted with no prefix cannot exempt the whole API (fails closed)', () => {
|
|
151
|
+
const router = express.Router();
|
|
152
|
+
const guard = defaultReadRateLimit(router);
|
|
153
|
+
router.use(guard);
|
|
154
|
+
router.use(accountExistenceReadRateLimit); // unprefixed: would match every path
|
|
155
|
+
router.get('/plain', ok);
|
|
156
|
+
const ip = '198.51.100.9';
|
|
157
|
+
for (let i = 0; i < DEFAULT_READ_LIMIT; i++) hit(guard, 'GET', '/plain', ip);
|
|
158
|
+
assert.equal(hit(guard, 'GET', '/plain', ip).nexted, false, 'the ceiling still applies');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('the exemption walk runs on Express 4 (its Layer internals), and fails here if that changes', () => {
|
|
162
|
+
assert.match(require('express/package.json').version, /^4\./,
|
|
163
|
+
'rate-limit.js reads Express 4 Layer#match / Route#_handles_method — re-verify defaultReadRateLimit before upgrading');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('readLimitOptOut refuses a missing or token reason', () => {
|
|
167
|
+
assert.throws(() => readLimitOptOut(), /reason/);
|
|
168
|
+
assert.throws(() => readLimitOptOut('nope'), /reason/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// The composed production router: the reads that are NOT on the default ceiling. The core
|
|
172
|
+
// mounts are checked here; modules are only loaded when enabled, so their limiters are pinned
|
|
173
|
+
// by the tag test below. Adding a per-surface limiter or an opt-out changes this list on
|
|
174
|
+
// purpose — the diff is the review; a route that quietly stops being tagged is a REMOVAL.
|
|
175
|
+
test('the production router exempts exactly the core per-surface reads', () => {
|
|
176
|
+
const { buildGdsRouter } = require('../src/bongos/routes.js');
|
|
177
|
+
const got = selfMeteredRoutes(buildGdsRouter()).map((r) => `${r.scope} ${r.path}`).sort();
|
|
178
|
+
// The /public/* prefix mount has no route path of its own, so it is described by its regexp.
|
|
179
|
+
const isPublicPrefix = (g) => g.startsWith('public-read ') && g.includes('public') && g.includes('(?=');
|
|
180
|
+
assert.ok(got.some(isPublicPrefix), 'the /public/* prefix mount is exempt');
|
|
181
|
+
assert.deepEqual(got.filter((g) => !isPublicPrefix(g)).filter((g) => !g.startsWith('account-existence /access-requests')), [
|
|
182
|
+
'account-existence /auth/web/admission-status',
|
|
183
|
+
'opt-out /healthz',
|
|
184
|
+
'public-read /builders/:id/profile',
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('every module-side read limiter wears the self-metered tag', () => {
|
|
189
|
+
assert.equal(require('../modules/provisioning/rate-limit.js').makeAskRateLimiter().meteredScope, 'provisioning-ask');
|
|
190
|
+
const { createReadRateLimit } = require('../modules/platform-identity/read-rate-limit.js');
|
|
191
|
+
assert.equal(createReadRateLimit({ scope: 'x-read', limit: 5 }).meteredScope, 'x-read');
|
|
192
|
+
assert.equal(publicProjectFeedRateLimit.meteredScope, 'public-project-feed');
|
|
193
|
+
assert.equal(accountExistenceReadRateLimit.meteredScope, 'account-existence');
|
|
194
|
+
});
|