@bongos/core 1.20.16 → 1.20.17
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 +15 -15
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +2 -2
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/read-rate-limit.js +6 -4
- package/modules/platform-identity/routes/scouting.js +27 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/recruiter_sliver_boundary.mjs +69 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.20.
|
|
6
|
-
"core_contract": "1.20.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.20.17",
|
|
6
|
+
"core_contract": "1.20.17",
|
|
7
|
+
"source_commit": "4e92fa5764cf7041b99baefbf8d249edbba9159d",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-30T13:31:15.089Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 556,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3210,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "4715c740ddb7d2c5cdfed3960679731b4a2cdef5b901f92945ba362f12f26570",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1547,7 +1547,7 @@
|
|
|
1547
1547
|
{
|
|
1548
1548
|
"path": "docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md",
|
|
1549
1549
|
"mode": "0000644",
|
|
1550
|
-
"sha256": "
|
|
1550
|
+
"sha256": "15d0455b7a29968c01ea01fb64d9c01fb63d7d2db0ef351df5f0fb9dfa677a94"
|
|
1551
1551
|
},
|
|
1552
1552
|
{
|
|
1553
1553
|
"path": "docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md",
|
|
@@ -2242,7 +2242,7 @@
|
|
|
2242
2242
|
{
|
|
2243
2243
|
"path": "docs/api/openapi.json",
|
|
2244
2244
|
"mode": "0000644",
|
|
2245
|
-
"sha256": "
|
|
2245
|
+
"sha256": "829ba538e06cca081f0540bf819f280ba36e738a553e5fa3634af5f5af5bc5b0"
|
|
2246
2246
|
},
|
|
2247
2247
|
{
|
|
2248
2248
|
"path": "docs/architecture.md",
|
|
@@ -2772,7 +2772,7 @@
|
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/module-api-changelog.md",
|
|
2774
2774
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2775
|
+
"sha256": "c2490d5cdc3d8044ad02ba2ade0a9918e8c3d041e79f0ac0d61f3126152053d1"
|
|
2776
2776
|
},
|
|
2777
2777
|
{
|
|
2778
2778
|
"path": "docs/modules-contract.md",
|
|
@@ -7057,7 +7057,7 @@
|
|
|
7057
7057
|
{
|
|
7058
7058
|
"path": "modules/platform-identity/read-rate-limit.js",
|
|
7059
7059
|
"mode": "0000644",
|
|
7060
|
-
"sha256": "
|
|
7060
|
+
"sha256": "fb22fef7442c1488499dce4ec8aafce30400166df51f5c7bd88577c83fb8873e"
|
|
7061
7061
|
},
|
|
7062
7062
|
{
|
|
7063
7063
|
"path": "modules/platform-identity/recruiter-sliver.js",
|
|
@@ -7112,7 +7112,7 @@
|
|
|
7112
7112
|
{
|
|
7113
7113
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
7114
7114
|
"mode": "0000644",
|
|
7115
|
-
"sha256": "
|
|
7115
|
+
"sha256": "3fbaaafe605fd891c449499ca85a716f9b8780bb1869f25b32162593ada8fedd"
|
|
7116
7116
|
},
|
|
7117
7117
|
{
|
|
7118
7118
|
"path": "modules/platform-identity/routes/sso.js",
|
|
@@ -8862,12 +8862,12 @@
|
|
|
8862
8862
|
{
|
|
8863
8863
|
"path": "package-lock.json",
|
|
8864
8864
|
"mode": "0000644",
|
|
8865
|
-
"sha256": "
|
|
8865
|
+
"sha256": "f04a559437cf80ab6eeb46905a8f943091dd4601e02b7d7ae58ca57aae35b050"
|
|
8866
8866
|
},
|
|
8867
8867
|
{
|
|
8868
8868
|
"path": "package.json",
|
|
8869
8869
|
"mode": "0000644",
|
|
8870
|
-
"sha256": "
|
|
8870
|
+
"sha256": "b8cd817f9d22b1222c06404648c197080fce17573ce0c83f43b4f03f87fb06b4"
|
|
8871
8871
|
},
|
|
8872
8872
|
{
|
|
8873
8873
|
"path": "public-docs/index.html",
|
|
@@ -8887,7 +8887,7 @@
|
|
|
8887
8887
|
{
|
|
8888
8888
|
"path": "release-notes.json",
|
|
8889
8889
|
"mode": "0000644",
|
|
8890
|
-
"sha256": "
|
|
8890
|
+
"sha256": "06f6c8587835146b2f325adf518aca1e74699cb2f38f0313ad4bf7f5fe37577c"
|
|
8891
8891
|
},
|
|
8892
8892
|
{
|
|
8893
8893
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -11002,7 +11002,7 @@
|
|
|
11002
11002
|
{
|
|
11003
11003
|
"path": "src/module-api.js",
|
|
11004
11004
|
"mode": "0000644",
|
|
11005
|
-
"sha256": "
|
|
11005
|
+
"sha256": "ae8085fdd1b2e41ff442de4a863b7b105bebaad915054a0cfa38bb57321289be"
|
|
11006
11006
|
},
|
|
11007
11007
|
{
|
|
11008
11008
|
"path": "src/module-loader/catalog.js",
|
|
@@ -14842,7 +14842,7 @@
|
|
|
14842
14842
|
{
|
|
14843
14843
|
"path": "tests/recruiter_sliver_boundary.mjs",
|
|
14844
14844
|
"mode": "0000644",
|
|
14845
|
-
"sha256": "
|
|
14845
|
+
"sha256": "34c56222c40ab58be128f380a2881a2d17bd6c483560d878f6de01a00bcef297"
|
|
14846
14846
|
},
|
|
14847
14847
|
{
|
|
14848
14848
|
"path": "tests/redteam_followup_kind.mjs",
|
|
@@ -61,7 +61,7 @@ A **previous** handle resolves to nothing here either, deliberately unlike `reso
|
|
|
61
61
|
|
|
62
62
|
### D4 — The sliver holds spec D3's **archon floor**; the directory's wider door is not reused, and the audience goes to the owner
|
|
63
63
|
|
|
64
|
-
`GET /scouting/:handle` sits in `modules/platform-identity/routes/scouting.js` beside `GET /scouting`, and the two do **not** share a gate. The sliver is `requireBuilder` → `api.requireRank('archon')
|
|
64
|
+
`GET /scouting/:handle` sits in `modules/platform-identity/routes/scouting.js` beside `GET /scouting`, and the two do **not** share a gate. The sliver is `requireBuilder` → `api.requireRank('archon')` (behind its own read budget since [task 1003540](https://cloudbongos.com/builders#/task/1003540) — see the last honest limit); the directory keeps `requireScoutingReader({ curateGate: requirePermission('project.curate') })`. Each gate is spelled in its own route's middleware list, so `route-rank-check.js` and `gen-api-docs.js` classify each from its real floor rather than recording a privileged route as `any-builder`.
|
|
65
65
|
|
|
66
66
|
Spec D3 says the sliver is "gated at the route by `project.recruit`" — and that atom **does not exist**. ADR 0201 D3 and ADR 0210 both refuse to mint it, on the same grounds: it belongs to the project-admin-console wave ([spike 1002786](https://cloudbongos.com/builders#/task/1002786) R3), which defines it, and minting it in a third place would fork its definition across three waves before any had shipped. **So which door does it use meanwhile?** The first draft reused the directory's, and that was wrong in the dangerous direction. Spec D1 declares the missing atom's shape — **floor archon**, widenable per instance through the governance tab — while the directory admits `project.curate` at the **metic** floor (ADR 0157) **OR any project owner**, who is typically a bare `xenos` on the hub (ADR 0210 D2). That door is *wider* than the signed floor on both halves. Reusing it would have widened an owner-signed privacy decision by implementation convenience, on the first surface that discloses a private account — and narrowing after people have been disclosed is not a recoverable move, while widening later is. So the sliver holds D3's declared floor, by rank, until the atom exists.
|
|
67
67
|
|
|
@@ -102,7 +102,7 @@ Both columns exist (migration `platform_identity_012_account_privacy.sql`); the
|
|
|
102
102
|
- **A recruiting read added in another FILE would not trip the two-route guard.** D6's count is over `routes/scouting.js` alone, so the "both recruiting surfaces" copy could become false without reddening. Sweeping every route file for reach-joining reads is a structural guard of its own and belongs with the shared-predicate work, not here.
|
|
103
103
|
- **`getRecruiterSliver` (the by-id port spec D3 names) has no production caller yet** — [task 1002972](https://cloudbongos.com/builders#/task/1002972) is its consumer. The route uses the by-handle sibling; the proof exercises both.
|
|
104
104
|
- **`active_recently` is coarse by design and coarse in practice.** It is derived from `max(last_active)` across the rollup, which is refreshed on federated sign-in and on ship — so a builder active only on a project that never reports will read as inactive. That understates rather than overstates, which is the safe direction for a privacy surface.
|
|
105
|
-
-
|
|
105
|
+
- **~~This route is not rate-limited~~ — closed by [task 1003540](https://cloudbongos.com/builders#/task/1003540); what was open, and why the fix took the shape it did.** `GET /profiles/:handle` carries a per-IP read limiter so the handle→identity mapping cannot be scripted at line rate (task 1002798), and `GET /scouting/:handle` shipped with none: a reader could probe guessed handles and learn *which private accounts opted into recruiting*, a set no other surface publishes — bounded by needing to guess a private handle, so a widening of degree, not of kind. It now spends its **own** per-IP budget, `recruiter-sliver-read`, at the `/public/*` posture (120 reads / 60s), mounted **ahead of** `requireBuilder`: the tag takes the route off core's default read ceiling (`rate-limit.js` #6), so a limiter behind the gate would have left every refused probe unmetered. It is **not** `accountExistenceReadRateLimit` — ADR 0209 is one budget per oracle, and sharing would hand an enumerator the sum of two. This note used to name a **doorway-exported factory** as the fix. That was wrong on ADR 0209's own terms, which rejects a limiter factory on the doorway as *"more general and more wrong"* (two call sites with matching config get two budgets), and it was overtaken: the module has had its own single limiter factory since task 1002978 (`modules/platform-identity/read-rate-limit.js`, mounted by the community and guild reads). The sliver's budget is one instance from that factory, built at module scope so every router the file builds spends from the same bucket — no new copy, and no change to the doorway. `tests/recruiter_sliver_boundary.mjs` §6b pins the budget, its position ahead of the gate, and that it is none of core's shared instances.
|
|
106
106
|
|
|
107
107
|
**Rejected:**
|
|
108
108
|
|
package/docs/api/openapi.json
CHANGED
|
@@ -16630,7 +16630,7 @@
|
|
|
16630
16630
|
"scouting"
|
|
16631
16631
|
],
|
|
16632
16632
|
"summary": "GET /scouting/:handle",
|
|
16633
|
-
"description": "the PRE-ACCEPT SLIVER for ONE builder (privacy spec D3, ADR 0215 D4, task 1002968). This is the FIRST surface on the platform that discloses a PRIVATE account, and its audience is deliberately NOT the directory's. WHY A RANK GATE AND NOT THE RECRUITING DOOR ABOVE. Spec D3 gates this read on `project.recruit`, an atom whose declared shape is FLOOR ARCHON, widenable per instance (spec D1). That atom does not exist — ADR 0201 D3 and ADR 0210 both refuse to mint it outside the project-admin-console wave that defines it (spike 1002786 R3), and this task does not either. The directory's door (`project.curate` at metic, OR any project owner — ADR 0210 D2) is WIDER than the signed floor on both halves, so adopting it here would widen an owner-signed privacy decision by implementation convenience. Until the atom lands, the honest reading of D3 is its floor, held by rank. Whether the wider recruiting audience is authorized for this surface is the owner's call and is filed as a blocker, NOT resolved here; when it is answered, this one line moves and nothing else does. 404, never 403, for a builder who is not recruiter-reachable: a 403 would confirm the handle exists, which is the disclosure the opt-out removes. The sliver's own predicate does the deciding, inside the module.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).",
|
|
16633
|
+
"description": "the PRE-ACCEPT SLIVER for ONE builder (privacy spec D3, ADR 0215 D4, task 1002968). This is the FIRST surface on the platform that discloses a PRIVATE account, and its audience is deliberately NOT the directory's. WHY A RANK GATE AND NOT THE RECRUITING DOOR ABOVE. Spec D3 gates this read on `project.recruit`, an atom whose declared shape is FLOOR ARCHON, widenable per instance (spec D1). That atom does not exist — ADR 0201 D3 and ADR 0210 both refuse to mint it outside the project-admin-console wave that defines it (spike 1002786 R3), and this task does not either. The directory's door (`project.curate` at metic, OR any project owner — ADR 0210 D2) is WIDER than the signed floor on both halves, so adopting it here would widen an owner-signed privacy decision by implementation convenience. Until the atom lands, the honest reading of D3 is its floor, held by rank. Whether the wider recruiting audience is authorized for this surface is the owner's call and is filed as a blocker, NOT resolved here; when it is answered, this one line moves and nothing else does. 404, never 403, for a builder who is not recruiter-reachable: a 403 would confirm the handle exists, which is the disclosure the opt-out removes. The sliver's own predicate does the deciding, inside the module. Rate-limited per IP (120 reads / 60s, 429 scope `recruiter-sliver-read`), and the budget is spent FIRST, ahead of the gate: its tag takes this route off core's default read ceiling, so a limiter behind requireBuilder would leave every refused probe unmetered.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).",
|
|
16634
16634
|
"x-rank": "archon",
|
|
16635
16635
|
"x-source": "modules/platform-identity/routes/scouting.js",
|
|
16636
16636
|
"parameters": [
|
|
@@ -2689,5 +2689,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2689
2689
|
landed since 1.20.14 with no explicit bump. run 36713710510. (task 1002620)
|
|
2690
2690
|
1.20.16 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2691
2691
|
landed since 1.20.15 with no explicit bump. run 36717488992. (task 1002620)
|
|
2692
|
+
1.20.17 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2693
|
+
landed since 1.20.16 with no explicit bump. run 36722139698. (task 1002620)
|
|
2692
2694
|
---------------------------------------------------------------------------
|
|
2693
2695
|
```
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// modules/platform-identity/read-rate-limit.js — a per-IP read cap for
|
|
2
|
-
//
|
|
1
|
+
// modules/platform-identity/read-rate-limit.js — a per-IP read cap for a GET
|
|
2
|
+
// in this module that must not be scripted at line rate. The global limiter meters writes
|
|
3
3
|
// only, and core's per-IP sliding window (src/bongos/middleware/rate-limit.js
|
|
4
4
|
// perIpSlidingWindow) reaches modules only as ready-built instances on the
|
|
5
5
|
// doorway (ADR 0209: an instance per budget), none of which is this module's.
|
|
6
6
|
// This is the module-side twin of that function — same 429 envelope and
|
|
7
7
|
// headers — written once (task 1002978) and mounted by the community reads
|
|
8
|
-
// (routes/community-leaderboard.js, routes/community-search.js)
|
|
9
|
-
// guild read (routes/guilds-public.js)
|
|
8
|
+
// (routes/community-leaderboard.js, routes/community-search.js), the public
|
|
9
|
+
// guild read (routes/guilds-public.js) and the recruiter sliver
|
|
10
|
+
// (routes/scouting.js GET /scouting/:handle, task 1003540 — authenticated, but a
|
|
11
|
+
// handle probe there reveals who opted into recruiting).
|
|
10
12
|
// routes/public-profile.js and routes/sso.js still carry older vendored copies.
|
|
11
13
|
'use strict';
|
|
12
14
|
|
|
@@ -41,9 +41,26 @@ const pi = require('../platform-identity');
|
|
|
41
41
|
const scoutingAuthz = require('../scouting-authz');
|
|
42
42
|
const recruiterSliver = require('../recruiter-sliver');
|
|
43
43
|
const scoutingNameOnly = require('../scouting-name-only');
|
|
44
|
+
const { createReadRateLimit } = require('../read-rate-limit');
|
|
44
45
|
|
|
45
46
|
const { pool, requireBuilder, parsePagination } = api;
|
|
46
47
|
|
|
48
|
+
// The sliver's per-IP read budget (task 1003540, ADR 0215). Probing guessed
|
|
49
|
+
// handles tells a reader WHICH private accounts opted into recruiting — a set no
|
|
50
|
+
// other surface publishes — so the probe is metered at the /public/* posture.
|
|
51
|
+
// Its OWN budget, never accountExistenceReadRateLimit: ADR 0209 is one budget per
|
|
52
|
+
// oracle, and sharing hands an enumerator the sum of two. Built ONCE at module
|
|
53
|
+
// scope, so every router this file builds spends from the same bucket, by the
|
|
54
|
+
// module's one limiter factory rather than a vendored copy. Not a doorway
|
|
55
|
+
// factory: ADR 0209 rejected that shape because two call sites get two budgets.
|
|
56
|
+
const SLIVER_READ_WINDOW_MS = 60 * 1000;
|
|
57
|
+
const SLIVER_READ_LIMIT = 120;
|
|
58
|
+
const sliverReadRateLimit = createReadRateLimit({
|
|
59
|
+
scope: 'recruiter-sliver-read',
|
|
60
|
+
limit: SLIVER_READ_LIMIT,
|
|
61
|
+
windowMs: SLIVER_READ_WINDOW_MS,
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
// Resolved at CALL time, never at load. Proofs that exercise this route pin a
|
|
48
65
|
// PARTIAL doorway (tests/profile_route.mjs stubs pool + the two gates and
|
|
49
66
|
// nothing else), and a module-scope api.logger() would make this file
|
|
@@ -118,8 +135,14 @@ module.exports = function scoutingRoutes() {
|
|
|
118
135
|
// 404, never 403, for a builder who is not recruiter-reachable: a 403 would
|
|
119
136
|
// confirm the handle exists, which is the disclosure the opt-out removes. The
|
|
120
137
|
// sliver's own predicate does the deciding, inside the module.
|
|
138
|
+
//
|
|
139
|
+
// Rate-limited per IP (120 reads / 60s, 429 scope `recruiter-sliver-read`),
|
|
140
|
+
// and the budget is spent FIRST, ahead of the gate: its tag takes this route
|
|
141
|
+
// off core's default read ceiling, so a limiter behind requireBuilder would
|
|
142
|
+
// leave every refused probe unmetered.
|
|
121
143
|
router.get(
|
|
122
144
|
'/scouting/:handle',
|
|
145
|
+
sliverReadRateLimit,
|
|
123
146
|
requireBuilder,
|
|
124
147
|
api.requireRank('archon'),
|
|
125
148
|
async (req, res) => {
|
|
@@ -139,3 +162,7 @@ module.exports = function scoutingRoutes() {
|
|
|
139
162
|
|
|
140
163
|
return router;
|
|
141
164
|
};
|
|
165
|
+
|
|
166
|
+
// Exported for tests — the sliver's budget instance and its limits (the
|
|
167
|
+
// routes/community-search.js precedent).
|
|
168
|
+
module.exports._internals = { sliverReadRateLimit, SLIVER_READ_LIMIT, SLIVER_READ_WINDOW_MS };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.17",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.17",
|
|
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.20.
|
|
3
|
+
"version": "1.20.17",
|
|
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
|
@@ -8152,5 +8152,11 @@
|
|
|
8152
8152
|
"id": "1004185",
|
|
8153
8153
|
"text": "Cleaned out leftover code for a hosting option we retired, with proof that every live project's setup is unchanged — no project sees any difference."
|
|
8154
8154
|
}
|
|
8155
|
+
],
|
|
8156
|
+
"1.20.17": [
|
|
8157
|
+
{
|
|
8158
|
+
"id": "1003540",
|
|
8159
|
+
"text": "Guessing someone's private profile name on the recruiting page is now speed-limited, so it can't be used to discover who is open to recruiting."
|
|
8160
|
+
}
|
|
8155
8161
|
]
|
|
8156
8162
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.17'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -57,6 +57,8 @@
|
|
|
57
57
|
// project owner — is wider on both halves. A metic operator and a project owner
|
|
58
58
|
// both read the directory and are both refused here, which is asserted in both
|
|
59
59
|
// directions so a later "tidy-up" onto one shared gate cannot pass quietly.
|
|
60
|
+
// And the read BUDGET ahead of that gate (§6b, task 1003540): the sliver's own
|
|
61
|
+
// per-IP bucket, never a shared one (ADR 0209), so a handle probe is metered.
|
|
60
62
|
//
|
|
61
63
|
// MECHANISM (tests/profile_privacy_owner_view.mjs + tests/connections_ownership.mjs):
|
|
62
64
|
// in-memory tables whose row filter is built FROM EACH STATEMENT'S OWN WHERE
|
|
@@ -802,6 +804,73 @@ test('each route spells its OWN gate in its OWN middleware list', () => {
|
|
|
802
804
|
'and the sliver must never pick up the directory wider door by accident');
|
|
803
805
|
});
|
|
804
806
|
|
|
807
|
+
// ===========================================================================
|
|
808
|
+
// 6b. THE READ BUDGET — its own, and ahead of the gate (task 1003540)
|
|
809
|
+
// ===========================================================================
|
|
810
|
+
|
|
811
|
+
// The middleware list of one route, as the router a fresh scoutingRoutes() builds.
|
|
812
|
+
const handlersOf = (routePath) => buildScoutingRoutes().stack
|
|
813
|
+
.find((l) => l.route && l.route.path === routePath).route.stack.map((l) => l.handle);
|
|
814
|
+
|
|
815
|
+
test('the sliver spends its OWN per-IP read budget, and one budget however many routers mount it', () => {
|
|
816
|
+
// Probing guessed handles tells a reader WHICH private accounts opted into
|
|
817
|
+
// recruiting — a set no other surface publishes — so the probe is metered
|
|
818
|
+
// (ADR 0215's honest limit, closed). ADR 0209: one budget per oracle. Sharing
|
|
819
|
+
// an existing budget would hand an enumerator the sum of two, and a limiter
|
|
820
|
+
// built per router would hand a second mount a second budget.
|
|
821
|
+
const { sliverReadRateLimit } = buildScoutingRoutes._internals;
|
|
822
|
+
assert.equal(sliverReadRateLimit.meteredScope, 'recruiter-sliver-read');
|
|
823
|
+
assert.equal(handlersOf('/scouting/:handle')[0], sliverReadRateLimit);
|
|
824
|
+
assert.equal(handlersOf('/scouting/:handle')[0], handlersOf('/scouting/:handle')[0],
|
|
825
|
+
'two routers built from this file spend from ONE budget, not one each');
|
|
826
|
+
const core = require('../src/bongos/middleware/rate-limit.js');
|
|
827
|
+
for (const other of [core.accountExistenceReadRateLimit, core.publicProjectFeedRateLimit, core.publicReadRateLimitMiddleware]) {
|
|
828
|
+
assert.notEqual(sliverReadRateLimit, other, `the sliver must not spend the ${other.meteredScope} budget`);
|
|
829
|
+
}
|
|
830
|
+
assert.ok(!handlersOf('/scouting').includes(sliverReadRateLimit),
|
|
831
|
+
'the directory is a different read and does not spend the sliver budget');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('the budget runs AHEAD of the gate, so an unauthenticated probe is metered too', () => {
|
|
835
|
+
// A tagged route is skipped by core's default read ceiling (rate-limit.js #6).
|
|
836
|
+
// Behind requireBuilder, this limiter would leave every refused probe unmetered
|
|
837
|
+
// — looser than the ceiling it replaces.
|
|
838
|
+
const handlers = handlersOf('/scouting/:handle');
|
|
839
|
+
const { sliverReadRateLimit } = buildScoutingRoutes._internals;
|
|
840
|
+
assert.equal(handlers.indexOf(sliverReadRateLimit), 0, 'the limiter is the first handler on the route');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('120 reads a minute per IP, then a 429 that names the sliver budget', () => {
|
|
844
|
+
const { sliverReadRateLimit, SLIVER_READ_LIMIT, SLIVER_READ_WINDOW_MS } = buildScoutingRoutes._internals;
|
|
845
|
+
assert.equal(SLIVER_READ_LIMIT, 120, 'the /public/* posture');
|
|
846
|
+
assert.equal(SLIVER_READ_WINDOW_MS, 60 * 1000);
|
|
847
|
+
const mkRes = () => {
|
|
848
|
+
const r = { _status: 200, _json: null, _headers: {} };
|
|
849
|
+
r.set = (k, v) => { r._headers[k] = v; return r; };
|
|
850
|
+
r.status = (s) => { r._status = s; return r; };
|
|
851
|
+
r.json = (j) => { r._json = j; return r; };
|
|
852
|
+
attachFail({}, r, () => {});
|
|
853
|
+
return r;
|
|
854
|
+
};
|
|
855
|
+
// A documentation-range IP of its own, so the HTTP tests above keep their bucket.
|
|
856
|
+
const req = { ip: '203.0.113.140' };
|
|
857
|
+
let allowed = 0;
|
|
858
|
+
for (let i = 0; i < SLIVER_READ_LIMIT; i++) sliverReadRateLimit(req, mkRes(), () => { allowed++; });
|
|
859
|
+
assert.equal(allowed, SLIVER_READ_LIMIT, 'every read up to the cap passes');
|
|
860
|
+
const res = mkRes();
|
|
861
|
+
let nexted = false;
|
|
862
|
+
sliverReadRateLimit(req, res, () => { nexted = true; });
|
|
863
|
+
assert.equal(nexted, false, 'the read past the cap never reaches the gate or the query');
|
|
864
|
+
assert.equal(res._status, 429);
|
|
865
|
+
assert.equal(res._json.error.code, 'rate_limited');
|
|
866
|
+
assert.equal(res._headers['X-RateLimit-Scope'], 'recruiter-sliver-read');
|
|
867
|
+
assert.equal(res._headers['X-RateLimit-Limit'], '120');
|
|
868
|
+
assert.ok(Number(res._headers['Retry-After']) >= 1, 'a Retry-After header is set');
|
|
869
|
+
let otherPassed = false;
|
|
870
|
+
sliverReadRateLimit({ ip: '203.0.113.141' }, mkRes(), () => { otherPassed = true; });
|
|
871
|
+
assert.equal(otherPassed, true, 'one address hitting the cap does not throttle another');
|
|
872
|
+
});
|
|
873
|
+
|
|
805
874
|
// ===========================================================================
|
|
806
875
|
// 7. IT GRANTS NOTHING (ADR 0016)
|
|
807
876
|
// ===========================================================================
|