@bongos/core 1.20.15 → 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 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.15",
6
- "core_contract": "1.20.15",
7
- "source_commit": "139b563191ef97af7e538f783d3fb256baa6c075",
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-30T12:16:56.172Z",
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": "814cbc7e5df7346b441a3ac6faa4217852105b51708505d98b9e090d68bd8b6a",
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": "828e785306df71d1620e0bb541d0ce3cce9ff65dcd996adc4a817803000cbb7c"
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": "c1360ad6a6659819a5a2db72cef07fd3631a3052745ef4fcf20075e9849f7fbd"
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": "05833e301ee659bde426bdae2d327183b03a6e40b08230a07ce99ba8ccef9705"
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": "dc11b89460e4cca22af1ab61672104ac99298f477c1bad890d688f87456494fa"
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": "fbd1f8679be51718131d3d95d6ed7d8dcd1ebe091df6cfc190b69fd62c00013c"
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": "cc8381c4866cb96ae92e56199b4666a515f8c4fb6aa0214fe332a2c960f5c9df"
8865
+ "sha256": "f04a559437cf80ab6eeb46905a8f943091dd4601e02b7d7ae58ca57aae35b050"
8866
8866
  },
8867
8867
  {
8868
8868
  "path": "package.json",
8869
8869
  "mode": "0000644",
8870
- "sha256": "5125b2ece4cbf29c1c89b0eec97b4bce224fa0b25c8b3351e0b6253ade41c8e5"
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": "d12e72f6946fb8c0884d7b014d8673e58314ad5ff4d01593d397f5d96d8c8a6e"
8890
+ "sha256": "06f6c8587835146b2f325adf518aca1e74699cb2f38f0313ad4bf7f5fe37577c"
8891
8891
  },
8892
8892
  {
8893
8893
  "path": "scripts/bongos-mcp.js",
@@ -9842,7 +9842,7 @@
9842
9842
  {
9843
9843
  "path": "scripts/gds/provision-config.js",
9844
9844
  "mode": "0000644",
9845
- "sha256": "eef551d38110a43e295f465a9a61b0ac136ac14e79f40821c2ea0501ee8fa58a"
9845
+ "sha256": "5d8c07ebba17c797d7c8506e199f408a9490418e74089f5441a6141d76400be5"
9846
9846
  },
9847
9847
  {
9848
9848
  "path": "scripts/gds/provision-core-upgrade.js",
@@ -9852,12 +9852,12 @@
9852
9852
  {
9853
9853
  "path": "scripts/gds/provision-disconnect.js",
9854
9854
  "mode": "0000644",
9855
- "sha256": "eabada0abfcca12b744952a0bea9402ef95946ce5685b1ed08ae0f9b38da24b6"
9855
+ "sha256": "43aabd985e5ec2b2eff12b5d624158c3240e7777887705228945fedf691d95c3"
9856
9856
  },
9857
9857
  {
9858
9858
  "path": "scripts/gds/provision-net.js",
9859
9859
  "mode": "0000644",
9860
- "sha256": "85c84ac0ceb00133acb0a86c5c48a3887908a7c2e51490ec9de549d7714f4103"
9860
+ "sha256": "cc3bd9146e6f513239f564df67fe3dd0a4b50e7889a759c7528c9a4d34e64ce4"
9861
9861
  },
9862
9862
  {
9863
9863
  "path": "scripts/gds/provision-pin-key.js",
@@ -9887,22 +9887,22 @@
9887
9887
  {
9888
9888
  "path": "scripts/gds/provision-repo.js",
9889
9889
  "mode": "0000644",
9890
- "sha256": "7ab829842156f06853991a96d1eb0d0e800013b5e7a50d4d02c42c7733d3a37b"
9890
+ "sha256": "d1fd247608db02805168cd306d27378cf2ac964bf8ac156019eb969cc7b410d4"
9891
9891
  },
9892
9892
  {
9893
9893
  "path": "scripts/gds/provision-teardown.js",
9894
9894
  "mode": "0000644",
9895
- "sha256": "b2bb567debb248f2488d26e4bb87fff1258d6226a490e054ba601febdad288a6"
9895
+ "sha256": "eca5ebb6a796e336b3e5c542de171923488a3f4edd24e2e5da07d0a53f239c23"
9896
9896
  },
9897
9897
  {
9898
9898
  "path": "scripts/gds/provision-units.js",
9899
9899
  "mode": "0000644",
9900
- "sha256": "876697e79f9a3e3933bbd7623f97655962e2d10c298b6ba5f27d5b75cb885f9d"
9900
+ "sha256": "2fd3cd47bce98f32ec151557908fdde48f91d7a3d7a144f7e3e7f77638eb5165"
9901
9901
  },
9902
9902
  {
9903
9903
  "path": "scripts/gds/provision.js",
9904
9904
  "mode": "0000644",
9905
- "sha256": "0f9b3bc9e540e9a1ff0c210ce58aef97963c8bdcdd4674b31267e5fd7af3fe43"
9905
+ "sha256": "8f03f6c0e043ba7d80e6c4543cc0d6abc8b2ccb8d4a7e40e814a6a14d6859440"
9906
9906
  },
9907
9907
  {
9908
9908
  "path": "scripts/gds/publish-credential-check.js",
@@ -11002,7 +11002,7 @@
11002
11002
  {
11003
11003
  "path": "src/module-api.js",
11004
11004
  "mode": "0000644",
11005
- "sha256": "d97c483c3599928877210a4ceea2c9a384cf61dc86d1fec156de8a95dce743e5"
11005
+ "sha256": "ae8085fdd1b2e41ff442de4a863b7b105bebaad915054a0cfa38bb57321289be"
11006
11006
  },
11007
11007
  {
11008
11008
  "path": "src/module-loader/catalog.js",
@@ -14512,12 +14512,12 @@
14512
14512
  {
14513
14513
  "path": "tests/provision.mjs",
14514
14514
  "mode": "0000644",
14515
- "sha256": "550f0342dd556bb0f974875edfc570ac8a4734938832a75d00b019d19db0f8ed"
14515
+ "sha256": "ad4c6e5d4dd342e8c2b183c6decd7a38c976b207b30e211042bed0a42f3f5f17"
14516
14516
  },
14517
14517
  {
14518
14518
  "path": "tests/provision_account_identity.mjs",
14519
14519
  "mode": "0000644",
14520
- "sha256": "9f62eea1b43a478334a83df52d0e81a3b3b67f4c4eea54680d2d8db3b25ee513"
14520
+ "sha256": "63a9052194dbeca658aab24b839f549ba3193b34f564001ad889dd5671166fd9"
14521
14521
  },
14522
14522
  {
14523
14523
  "path": "tests/provision_byo_host.mjs",
@@ -14532,7 +14532,7 @@
14532
14532
  {
14533
14533
  "path": "tests/provision_disconnect.mjs",
14534
14534
  "mode": "0000644",
14535
- "sha256": "20de6ef694e176459b690aac55351f97a77bf91a3076870ba43972a6b8b6e54c"
14535
+ "sha256": "8c117a05b0c4af8e4f6a465cad02d208210148aaa7e26adfe9098967911f1be5"
14536
14536
  },
14537
14537
  {
14538
14538
  "path": "tests/provision_render.mjs",
@@ -14547,17 +14547,17 @@
14547
14547
  {
14548
14548
  "path": "tests/provision_restart.mjs",
14549
14549
  "mode": "0000644",
14550
- "sha256": "b9cdf2ef4d1af83c084b77ce44d3c142a935fb25f3a275f052db0cf3ede9b205"
14550
+ "sha256": "caa345e6708d896b42c597a9f83919b4795222e61955c728975d56a873a04fb8"
14551
14551
  },
14552
14552
  {
14553
14553
  "path": "tests/provision_settings_apply.mjs",
14554
14554
  "mode": "0000644",
14555
- "sha256": "08761779d0e8e733dd4db9de9c90cd147037ac30909883ec36e104389bac1edf"
14555
+ "sha256": "b8721014fea6a0cb60335077fff10fe55ab0c745511ab680154cbc291e357f9c"
14556
14556
  },
14557
14557
  {
14558
14558
  "path": "tests/provision_teardown_address.mjs",
14559
14559
  "mode": "0000644",
14560
- "sha256": "45f771259689319eff14f2c90fb9ebb063b172c290731b195770b02e923a133b"
14560
+ "sha256": "e835c2b0b75640dc96954815c1f13ecd6d8fddcb08ae64fb0b6d98818959eb0a"
14561
14561
  },
14562
14562
  {
14563
14563
  "path": "tests/provisioning_app_status.mjs",
@@ -14842,7 +14842,7 @@
14842
14842
  {
14843
14843
  "path": "tests/recruiter_sliver_boundary.mjs",
14844
14844
  "mode": "0000644",
14845
- "sha256": "522df6e7930347ff312ef5b2cb98cfde3c53f7465a133618dd970872241bc2b3"
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')`; 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`.
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
- - **This route is not rate-limited, and there is an asymmetry worth naming.** `GET /profiles/:handle` carries a per-IP read limiter precisely so the handle→identity mapping cannot be scripted at line rate (task 1002798), and `GET /scouting/:handle` — which is authenticated and permission-gated, so it starts from a much stronger position — has none. The asymmetry that remains is specific: a reader of this surface can probe guessed handles unmetered and learn *which private accounts opted into recruiting*, a set no other surface publishes. It is bounded by needing to guess a private account's handle (nothing publishes it) and by the audience already holding the full rollup of every public builder, so it is a widening of degree, not of kind. It is **not** fixable by reusing `accountExistenceReadRateLimit`: ADR 0209's whole decision is one budget per oracle, and this is a different oracle — sharing it would hand an enumerator the sum of two budgets on the route it cares about. The right fix is a dedicated budget behind a doorway-exported factory, which is a decision about the doorway rather than about the sliver; filed rather than smuggled in here.
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
 
@@ -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": [
@@ -2687,5 +2687,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2687
2687
  landed since 1.20.13 with no explicit bump. run 36712510525. (task 1002620)
2688
2688
  1.20.15 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2689
2689
  landed since 1.20.14 with no explicit bump. run 36713710510. (task 1002620)
2690
+ 1.20.16 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
2690
2694
  ---------------------------------------------------------------------------
2691
2695
  ```
@@ -1,12 +1,14 @@
1
- // modules/platform-identity/read-rate-limit.js — a per-IP read cap for an
2
- // UNAUTHENTICATED public GET in this module. The global limiter meters writes
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) and the public
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.15",
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.15",
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.15",
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",
@@ -8146,5 +8146,17 @@
8146
8146
  "id": "1004177",
8147
8147
  "text": "Installing the Windows runner now tells you up front that it needs an administrator window, and why, instead of failing with a bare \"access denied\"; the setup guide no longer says the opposite."
8148
8148
  }
8149
+ ],
8150
+ "1.20.16": [
8151
+ {
8152
+ "id": "1004185",
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
+ }
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
+ }
8149
8161
  ]
8150
8162
  }
@@ -77,12 +77,11 @@ const CONFIG = {
77
77
  // systemd (ADR 0306). Unused on a systemd box. No sudo is applied to writes here: the
78
78
  // planes that select supervisord run unprivileged and already own this dir.
79
79
  supervisorConfDir: process.env.PROVISION_SUPERVISOR_CONF_DIR || '/etc/supervisor/conf.d',
80
- // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
81
- region: process.env.PROVISION_REGION || 'nyc3',
82
- baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
83
- tag: process.env.PROVISION_TAG || 'cloudbongos-instance',
80
+ // The DigitalOcean token. No runner leg spends it since the dedicated droplet path went
81
+ // (task 1004185); the read stays because it is what makes this file a registered
82
+ // credential reader (modules/government/protected-surfaces.json + the tripwire in
83
+ // tests/government_protected_surfaces.mjs). Dropping it is a governance change of its own.
84
84
  doToken: process.env.DO_API_TOKEN || null,
85
- sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
86
85
  // The Cloudflare DNS hook (the instance's infra/box-dns-cloudflare.sh).
87
86
  // P4 (#1945) formalizes the UPSERT: point the instance's domain at the target
88
87
  // public IP (below), in the domain's OWN Cloudflare zone, proxied=false (see
@@ -90,8 +89,7 @@ const CONFIG = {
90
89
  // still reachable by IP; TLS then can't be issued until DNS lands).
91
90
  dnsHook: process.env.BOX_DNS_HOOK || null,
92
91
  // This box's PUBLIC IP — the A-record target for a CO-TENANT instance's domain.
93
- // Set in the control-plane /etc/<inst>/box.env (never the web tier). A dedicated
94
- // instance instead points its A record at the freshly-created droplet's IP. Under
92
+ // Set in the control-plane /etc/<inst>/box.env (never the web tier). Under
95
93
  // remote-exec (PROVISION_REMOTE_HOST) this is the CO-HOSTING box's IP — where the
96
94
  // co-tenant actually runs (ADR 0130).
97
95
  publicIp: process.env.PROVISION_PUBLIC_IP || process.env.BOX_PUBLIC_IP || null,
@@ -159,8 +157,8 @@ const CONFIG = {
159
157
  // allows 97 non-superuser backends, so nine busy projects exhaust it whatever the
160
158
  // RAM headroom. Idle projects hold zero backends, so this bounds bursts, not the
161
159
  // steady state: 3 × 15 slots + the control plane's own 10 stays under the 97. The
162
- // control plane keeps pg's default (it is not provisioned by this runner), and a
163
- // dedicated droplet has its own server, so neither is written.
160
+ // control plane keeps pg's default (it is not provisioned by this runner), so it is
161
+ // not written there.
164
162
  instancePoolMax: numEnv('PROVISION_INSTANCE_PGPOOL_MAX', 3),
165
163
  };
166
164
 
@@ -188,8 +186,6 @@ function loadDeps() {
188
186
  const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
189
187
  // eslint-disable-next-line global-require
190
188
  const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
191
- // eslint-disable-next-line global-require
192
- const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
193
189
  // Cross-site identity (ADR 0141 §4, task #1002109). Resolve the hub-side module by a
194
190
  // VARIABLE key, gated on isModuleEnabled — the module-loader's own dynamic-dispatch
195
191
  // pattern (the run-unit-tests.js precedent), NOT a static 'modules/platform-identity/'
@@ -206,14 +202,14 @@ function loadDeps() {
206
202
  try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); projectCatalog = require(path.join(piBase, 'project-catalog')); }
207
203
  catch { platformIdentity = null; hubKeys = null; projectCatalog = null; }
208
204
  }
209
- _deps = { pool, provisioning, DoApi, platformIdentity, hubKeys, projectCatalog };
205
+ _deps = { pool, provisioning, platformIdentity, hubKeys, projectCatalog };
210
206
  return _deps;
211
207
  }
212
208
 
213
209
  // ---------------------------------------------------------------------------
214
210
  // PURE step-command generators (exported for tests). Each returns the exact shell
215
- // command a runbook step runs. Kept pure so local exec (co-tenant) and the
216
- // dedicated droplet's cloud-init both build from ONE source of truth.
211
+ // command a runbook step runs. Kept pure so the runbook and its tests build from ONE
212
+ // source of truth.
217
213
  // ---------------------------------------------------------------------------
218
214
 
219
215
  // A slug is validated DNS/db-safe upstream (provisioning.isValidSlug), so it is
@@ -62,12 +62,10 @@ async function disconnectInstance(inst, deps) {
62
62
  // A teardown returns ok or throws; a throw ends the run here, before anything is erased.
63
63
  await teardownInstance(inst, deps);
64
64
 
65
- if (inst.hosting_shape !== 'dedicated') {
66
- const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
67
- for (const cmd of eraseCommands(inst, { privileged })) {
68
- try { boxExec(cmd); }
69
- catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
70
- }
65
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
66
+ for (const cmd of eraseCommands(inst, { privileged })) {
67
+ try { boxExec(cmd); }
68
+ catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
71
69
  }
72
70
 
73
71
  const origin = catalogOriginForInstance(inst);
@@ -33,8 +33,7 @@ function federationHubOrigin() {
33
33
  // platform-identity module is present, a signing key is loadable, AND a hub origin
34
34
  // resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
35
35
  // written locally — a `standalone` is owner-hosted and keeps its own GitHub app, the
36
- // self-hosted path; a `dedicated` droplet has no local web.env write yet, same gap the
37
- // ADR 0133 manifest flow leaves — see placeManifestCreds), and it has a public domain
36
+ // self-hosted path), and it has a public domain
38
37
  // (the origin the hub binds the CLI mint audience to — the #1002108 account-takeover
39
38
  // fix). Returns { federate, reason } so the caller can log WHY it skipped. PURE-ish
40
39
  // (reads env + the injected module handles; no DB, no I/O).
@@ -110,7 +109,7 @@ function zoneForDomain(domain) {
110
109
  // The env a DNS UPSERT invocation of infra/box-dns-cloudflare.sh needs for a
111
110
  // PROVISIONED host: the hostname, the target public IP (the A-record content), the
112
111
  // zone the record lives in, and whether Cloudflare proxies it.
113
- // • proxied=false (default) is LOAD-BEARING for a CO-TENANT/dedicated host: it uses
112
+ // • proxied=false (default) is LOAD-BEARING for a CO-TENANT host: it uses
114
113
  // Caddy on-demand ACME (Let's Encrypt), whose challenge must reach Caddy directly —
115
114
  // Cloudflare proxy ON would intercept :80/:443 at the edge and the challenge would
116
115
  // silently fail (ADR 0003 / docs/recipes/ops-gotchas.md). Do NOT proxy those.
@@ -297,11 +296,9 @@ function ingressPlaneGap(exec, { caddyfile = CONFIG.caddyfile } = {}) {
297
296
  }
298
297
  // The preflight around ingressPlaneGap: the narration its sibling preflights already carry,
299
298
  // including the dry-run line without which the plan prints step 6's Caddy write as though it
300
- // will work. Returns the refusal, or null to proceed. `dedicated` skips it (Caddy comes from
301
- // cloud-init). Lives here rather than inline at the two call sites to keep provision.js under
302
- // its 1,500-line ratchet.
303
- function ingressPreflight({ exec, apply, log, shape, probe = ingressPlaneGap, before = 'writing any Caddy config' }) {
304
- if (shape === 'dedicated') return null;
299
+ // will work. Returns the refusal, or null to proceed. Lives here rather than inline at the two
300
+ // call sites to keep provision.js under its 1,500-line ratchet.
301
+ function ingressPreflight({ exec, apply, log, probe = ingressPlaneGap, before = 'writing any Caddy config' }) {
305
302
  if (!apply) { log(` [preflight] would probe the plane for a reverse proxy before ${before}`); return null; }
306
303
  const missing = probe(exec);
307
304
  if (missing) return `Ingress preflight failed: ${missing}`;
@@ -62,9 +62,8 @@ function migrateCmd(inst, { privileged = false } = {}) {
62
62
  // The id/name/done_when come from instanceInitSpec so the starter version has ONE
63
63
  // source of truth (the same spec the greenfield `bongos init` seed uses). SQL string
64
64
  // literals are DOLLAR-QUOTED ($v$…$v$) so the whole -c argument is single-quoted —
65
- // the one form that is safe UNCHANGED across all three executors: inline (/bin/sh),
66
- // over SSH (bash -s), AND embedded in a dedicated droplet's cloud-init (bash -lc "…"),
67
- // with no nested-quote or $-expansion hazard.
65
+ // the one form that is safe UNCHANGED across both executors: inline (/bin/sh) and
66
+ // over SSH (bash -s), with no nested-quote or $-expansion hazard.
68
67
  function seedFirstVersionCmd(inst, { privileged = false } = {}) {
69
68
  const v = instanceInitSpec(inst).version;
70
69
  const q = (s) => `$v$${s}$v$`; // dollar-quoted literal (no value contains a single quote or $)
@@ -299,10 +298,8 @@ function accountPlaneGap(exec, { privileged = false } = {}) {
299
298
 
300
299
  // The preflight around accountPlaneGap: the narration its sibling preflights carry,
301
300
  // including the dry-run line without which the plan prints step 1c's useradd as though it
302
- // will work. Returns the refusal, or null to proceed. `dedicated` skips it — that account
303
- // is created by cloud-init, which runs as root. Lives here beside the account it guards.
304
- function accountPreflight({ exec, apply, log, shape, privileged = false, probe = accountPlaneGap }) {
305
- if (shape === 'dedicated') return null;
301
+ // will work. Returns the refusal, or null to proceed. Lives here beside the account it guards.
302
+ function accountPreflight({ exec, apply, log, privileged = false, probe = accountPlaneGap }) {
306
303
  if (!apply) { log(' [preflight] would probe whether the runner can create this instance\'s own unix account'); return null; }
307
304
  const missing = probe(exec, { privileged });
308
305
  if (missing) return `Account preflight failed: ${missing}`;
@@ -327,38 +324,20 @@ function accountPreflight({ exec, apply, log, shape, privileged = false, probe =
327
324
  //
328
325
  // QUOTING, which is load-bearing in a way that is easy to undo by accident. Every literal
329
326
  // is DOLLAR-QUOTED and every identifier goes through format(%I), so the rendered command
330
- // contains no single quote and NO DOUBLE QUOTE. It has to survive three executors
331
- // unchanged — inline /bin/sh, `bash -s` over SSH, and, the strict one, `- [ bash, -lc,
332
- // "..." ]` inside the dedicated droplet's cloud-init, which is a YAML DOUBLE-QUOTED
333
- // scalar. A plain "role" identifier reads fine in psql and silently truncates the YAML.
334
- // seedFirstVersionCmd dollar-quotes for the same reason; a test asserts the absence.
335
- // `peerOnly` is the DEDICATED shape's mode, and it exists because of where that
336
- // command TRAVELS (task 1003369). A dedicated droplet's steps ride DigitalOcean
337
- // cloud-init user-data, which DO RETAINS and serves back through its API, its console
338
- // and the droplet's own metadata endpoint — so a password embedded there is a second,
339
- // permanent copy of the credential in a place this task cannot lock down, which is
340
- // strictly worse than what it protects against. A dedicated droplet is SINGLE-TENANT:
341
- // there are no siblings on it, so the co-tenancy vector B3 describes does not exist,
342
- // and the role can peer-auth to its own unix account. The role, its DB ownership and
343
- // the PUBLIC revoke are still created — only the password is omitted, deliberately.
344
- function dbRoleCmd(inst, { password = null, peerOnly = false, privileged = false } = {}) {
327
+ // contains no single quote and NO DOUBLE QUOTE, so it survives both executors unchanged —
328
+ // inline /bin/sh and `bash -s` over SSH. seedFirstVersionCmd dollar-quotes for the same
329
+ // reason; a test asserts the absence.
330
+ function dbRoleCmd(inst, { password = null, privileged = false } = {}) {
345
331
  const role = instanceDbRole(inst), db = dbName(inst);
346
- if (peerOnly) {
347
- if (password) throw new Error('dbRoleCmd: peerOnly takes no password — it exists to keep one out of cloud-init user-data');
348
- } else if (!/^[A-Za-z0-9_-]+$/.test(String(password || ''))) {
332
+ if (!/^[A-Za-z0-9_-]+$/.test(String(password || ''))) {
349
333
  throw new Error('dbRoleCmd: password must be a non-empty base64url string (generateDbPassword)');
350
334
  }
351
- // Two shapes of the same upsert. peerOnly omits the PASSWORD clause entirely rather
352
- // than passing an empty one — `WITH LOGIN` leaves any existing password untouched.
353
- const login = peerOnly
354
- ? { clause: 'WITH LOGIN', args: '' }
355
- : { clause: 'WITH LOGIN PASSWORD %L', args: `, $p$${password}$p$` };
356
335
  const sql =
357
336
  `DO $do$ BEGIN ` +
358
337
  `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $r$${role}$r$) THEN ` +
359
- `EXECUTE format($f$ALTER ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
338
+ `EXECUTE format($f$ALTER ROLE %I WITH LOGIN PASSWORD %L$f$, $r$${role}$r$, $p$${password}$p$); ` +
360
339
  `ELSE ` +
361
- `EXECUTE format($f$CREATE ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
340
+ `EXECUTE format($f$CREATE ROLE %I WITH LOGIN PASSWORD %L$f$, $r$${role}$r$, $p$${password}$p$); ` +
362
341
  `END IF; ` +
363
342
  `EXECUTE format($f$ALTER DATABASE %I OWNER TO %I$f$, $d$${db}$d$, $r$${role}$r$); ` +
364
343
  `EXECUTE format($f$REVOKE CONNECT ON DATABASE %I FROM PUBLIC$f$, $d$${db}$d$); ` +
@@ -1158,7 +1137,7 @@ async function refreshStandaloneCorePin(inst, deps) {
1158
1137
  // bound to the instance's port + DB + brand, secrets from its own env file. SHAPE-AWARE:
1159
1138
  // a STANDALONE instance runs the CORE PACKAGE's platform-server from its OWN repo dir
1160
1139
  // (WorkingDirectory=standaloneRoot → resolveInstanceRoot() resolves to the instance via
1161
- // cwd, deploy-instance.sh §4); co-tenant/dedicated run this control-plane checkout's src/.
1140
+ // cwd, deploy-instance.sh §4); co-tenant runs this control-plane checkout's src/.
1162
1141
 
1163
1142
  module.exports = {
1164
1143
  appGroupExpr,
@@ -24,24 +24,20 @@ async function teardownInstance(inst, deps) {
24
24
  // Tear down on the box the instance actually RAN on: a standalone ran control-plane-
25
25
  // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
26
26
  // box, where it was never installed (that would allowFail-noop and leave the real
27
- // control-plane unit running). Co-tenant + dedicated are unchanged.
27
+ // control-plane unit running). Co-tenant is unchanged.
28
28
  const boxExec = inst.hosting_shape === 'cloud-host' ? controlExec : exec;
29
29
  const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
30
30
  log(`teardown ${inst.slug} (status=${inst.status})`);
31
31
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
32
- if (inst.hosting_shape !== 'dedicated') {
33
- // Stop + remove the service AND the nightly backup units (task 2213; the dir + dumps stay — precious data, like the DB below). Whoever supervises the instance owns its removal (ADR 0306) — the systemd manager keeps the reset-failed step task 1003940 added, since a removed unit's FAILED state outlives its file and `systemctl --failed` listed torn-down ghosts for weeks.
34
- (deps.processManager || resolveProcessManager()).removeService(inst, { exec: boxExec, sudoP });
35
- // The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
36
- // a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
37
- boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
38
- boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
39
- // Left for an operator (data is precious), naming ONLY what this row created —
40
- // teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
41
- if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
42
- } else if (apply && inst.droplet_id && deps.doApi) {
43
- await deps.doApi.deleteDroplet(inst.droplet_id);
44
- }
32
+ // Stop + remove the service AND the nightly backup units (task 2213; the dir + dumps stay — precious data, like the DB below). Whoever supervises the instance owns its removal (ADR 0306) — the systemd manager keeps the reset-failed step task 1003940 added, since a removed unit's FAILED state outlives its file and `systemctl --failed` listed torn-down ghosts for weeks.
33
+ (deps.processManager || resolveProcessManager()).removeService(inst, { exec: boxExec, sudoP });
34
+ // The site snippet standup step 6 wrote: while it stays, Caddy proxies the hostname to
35
+ // a port nothing listens on and every visitor gets a 502 (task 1002897). Not precious.
36
+ boxExec(`${sudoP}rm -f ${caddySnippetPath(inst)}`, { allowFail: true });
37
+ boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
38
+ // Left for an operator (data is precious), naming ONLY what this row created —
39
+ // teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
40
+ if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
45
41
  parkAddress(inst, { controlExec, log, apply });
46
42
  // The app's Render services are the OWNER's: forget them, never delete them (ADR 0327 §3, task 1004183).
47
43
  await require('./provision-render.js').forgetRenderApp(inst, { db, log, apply });