@bongos/core 1.19.1047 → 1.19.1049

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 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.1047",
6
- "core_contract": "1.19.1047",
7
- "source_commit": "a83322a2af0bab901b143a098fb0a5cc337f9e64",
5
+ "core_version": "1.19.1049",
6
+ "core_contract": "1.19.1049",
7
+ "source_commit": "9d25b951a2f85a4303027917ebf5c95d02cac06e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-26T23:42:01.064Z",
9
+ "built_at": "2026-09-27T13:43:34.143Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 543,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2512,
14
+ "functional_verbatim": 2513,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3082,
20
- "tree_sha256": "0fdbcc97739c2b07898a2eb701e2f0a991cf6b1f173450143e6df73720b60231",
19
+ "file_count": 3083,
20
+ "tree_sha256": "a84ed2035d842841f5e0b994df4754f714dc15c3ff0f080cef771d939cd37e7b",
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": "de85c4f3489d17d7b82c0cc64a3049a1cba09589f67e551ffcafbc9354ec3a8d"
2775
+ "sha256": "113ce655a21853395a81cfdcf467e9de8e1e8b46e5dfe15b1dd5f4d9401e0193"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -3027,7 +3027,7 @@
3027
3027
  {
3028
3028
  "path": "docs/recipes/upgrading-the-core.md",
3029
3029
  "mode": "0000644",
3030
- "sha256": "f8800c2b308a057b851b868eae07e6d2268b68e342de146dd341b677e64b46c8"
3030
+ "sha256": "169e4aedf245478230027ad6a202aa7dd2ce35f019d489d2ea4c0735647d0fe6"
3031
3031
  },
3032
3032
  {
3033
3033
  "path": "docs/recipes/windows-builders.md",
@@ -6812,7 +6812,7 @@
6812
6812
  {
6813
6813
  "path": "modules/platform-identity/read-rate-limit.js",
6814
6814
  "mode": "0000644",
6815
- "sha256": "db17891c79447950b68d55e6e81db6f3c524491bac5ee1caa375b0611c3248c7"
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": "e59f99d6f323e70611a08fa2c8299af815e350dbb81bd7cc79e9d96bd93cdbba"
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": "27046325beffad5d1edfcc91c331048038900ef7d32f1d38796755c7990ba202"
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": "71c9088d184288d783c7ca384d4cff140ec48251157bb3557c5c76854d1829e8"
8500
+ "sha256": "acb5560af98adc58a474b3aa9fd2280a803c10e58538fb31f4c7cbd6be8518e2"
8501
8501
  },
8502
8502
  {
8503
8503
  "path": "package.json",
8504
8504
  "mode": "0000644",
8505
- "sha256": "888aceb332ce59d940cc7c4b42b63a896d773a387b957e7581a10f9b4d2a56be"
8505
+ "sha256": "dd99e950d328fee80b5a502201e1a2193d15590a5fe489645beca54695f9a774"
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": "b2e95686e68d9e0321f3dba1807d8747ff43f2739aa3738c7d0eed8516eb20cd"
8525
+ "sha256": "e0a7dbc41f8706f31363a5ef24aed64d4176f23edb66c72e99911a58beee7835"
8526
8526
  },
8527
8527
  {
8528
8528
  "path": "scripts/bongos-mcp.js",
@@ -10377,7 +10377,7 @@
10377
10377
  {
10378
10378
  "path": "src/bongos/middleware/rate-limit.js",
10379
10379
  "mode": "0000644",
10380
- "sha256": "d19b83f04b32d5ca942fb2ed3a210af6ef8607e3e9c949338f816337a3b6cf8c"
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": "274cbf93d292dfb1d605ad318f5d94def6c05a06e71a14f7a47360f168045e1b"
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": "5d22031a1acd21a9a244d9f68809450e811fe6a6c98a2f4e2330452515757c37"
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": "948f2239e756563cee5b97cc98254d5041e1edcb283139087f7e07bbf3368441"
10595
+ "sha256": "2be8a6906c0dd4d9d3e4af313c25e885abdeafe0f917399d1775c3692ce64c8f"
10596
10596
  },
10597
10597
  {
10598
10598
  "path": "src/module-loader/catalog.js",
@@ -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",
@@ -2581,5 +2581,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2581
2581
  landed since 1.19.1045 with no explicit bump. run 36265788618. (task 1002620)
2582
2582
  1.19.1047 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2583
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)
2586
+ 1.19.1049 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2587
+ landed since 1.19.1048 with no explicit bump. run 36323359891. (task 1002620)
2584
2588
  ---------------------------------------------------------------------------
2585
2589
  ```
@@ -142,6 +142,26 @@ sudo systemctl daemon-reload && sudo systemctl restart <instance>-core-update-su
142
142
  systemctl list-timers '*core-update-subscription*' --no-pager # confirm the next elapse is minutes away
143
143
  ```
144
144
 
145
+ Three things to know before you run that restart (task 1003969, learned on
146
+ cloudbongos.com 2026-09-26):
147
+
148
+ - **The instance's installed core must be 1.19.1047 or later.** The sweep runs
149
+ `go-live.js` from the instance's OWN installed core, not from the checkout the
150
+ unit starts in. Before 1.19.1047 go-live wrote a full `pg_dump` *before*
151
+ `bongos upgrade` could refuse, and never pruned, so a deploy held by the artist
152
+ gate cost one dump per attempt: 65 MB every 15 minutes, a full disk in about
153
+ three days. From 1.19.1047 a refused move costs one `--dry-run` and nothing else,
154
+ and pre-upgrade dumps are capped at the newest ten (task 1004306).
155
+ - **Restarting a `Persistent=true` timer fires a catch-up run at once.** If a newer
156
+ version is published, the `restart` above starts a real upgrade within seconds,
157
+ restart of the site included. Run it when an upgrade is welcome.
158
+ - **A failing upgrade is retried every run.** Each attempt restarts the service
159
+ twice (the bump, then the auto-rollback), so at 15 minutes a version that keeps
160
+ failing means a brief blip four times an hour until it is fixed or the channel is
161
+ pinned. The one-day hold on a failed version (task 1004297) is the fix; until
162
+ every sweep carries it, look at the core_upgrades rows for `rolled_back:` notes
163
+ during the first day.
164
+
145
165
  **Do not go below ~5 minutes.** An upgrading run takes ~2 minutes; at a tighter
146
166
  spacing runs overlap. A run with nothing to take costs one `npm view` and exits,
147
167
  so 15 minutes is cheap — the cost scales with releases, not with checks.
@@ -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
- return function readRateLimit(req, res, next) {
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
- return function askRateLimit(req, res, next) {
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.1047",
3
+ "version": "1.19.1049",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1047",
9
+ "version": "1.19.1049",
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.1047",
3
+ "version": "1.19.1049",
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",
@@ -7583,5 +7583,17 @@
7583
7583
  "id": "1004306",
7584
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
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
+ }
7592
+ ],
7593
+ "1.19.1049": [
7594
+ {
7595
+ "id": "1003969",
7596
+ "text": "cloudbongos.com now checks for a new version every 15 minutes instead of once a day, so merged work goes live within about a quarter of an hour."
7597
+ }
7586
7598
  ]
7587
7599
  }
@@ -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) { bucket = []; buckets.set(ip, 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(),
@@ -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. Reads uncapped.
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.1047'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1049'; // 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');
@@ -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
+ });