@bongos/core 1.19.506 → 1.19.508
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 +30 -20
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +2 -0
- package/docs/copy-inventory.md +2 -2
- package/docs/copy-registry.json +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/builders.js +33 -6
- package/modules/hall-ui/public/hall-widgets.js +1 -1
- package/modules/lifecycle/routes/tasks.js +11 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +11 -1
- package/tests/hall_page_gate_map.mjs +71 -0
- package/tests/home_bounded_reads.mjs +159 -0
- package/tests/my_projects_surface.mjs +112 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.508",
|
|
6
|
+
"core_contract": "1.19.508",
|
|
7
|
+
"source_commit": "f80c3ed5ba139cbecb704b5cd064990b3ff9aaaf",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-03T18:24:24.662Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 427,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1947,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2398,
|
|
20
|
+
"tree_sha256": "8482fdb735f30dbc4866dbf952a55196a50e643fd932b0ef0bb5b701ada05eab",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -382,7 +382,7 @@
|
|
|
382
382
|
{
|
|
383
383
|
"path": "clients/bongos-client/index.d.ts",
|
|
384
384
|
"mode": "0000644",
|
|
385
|
-
"sha256": "
|
|
385
|
+
"sha256": "afe60dae1ca8b55c5f9521b4f23eaad4eb194627a2ee105cae1513745ae71544"
|
|
386
386
|
},
|
|
387
387
|
{
|
|
388
388
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1702,7 +1702,7 @@
|
|
|
1702
1702
|
{
|
|
1703
1703
|
"path": "docs/api/openapi.json",
|
|
1704
1704
|
"mode": "0000644",
|
|
1705
|
-
"sha256": "
|
|
1705
|
+
"sha256": "0b5eab5940ae6cf0e8691c6ec59757e8bbfb719a660ec3e8492d9870c95a8c62"
|
|
1706
1706
|
},
|
|
1707
1707
|
{
|
|
1708
1708
|
"path": "docs/architecture.md",
|
|
@@ -1727,12 +1727,12 @@
|
|
|
1727
1727
|
{
|
|
1728
1728
|
"path": "docs/copy-inventory.md",
|
|
1729
1729
|
"mode": "0000644",
|
|
1730
|
-
"sha256": "
|
|
1730
|
+
"sha256": "b6b680304b158480b87c61679bafa3431d6e492aa9323933abf68621ce243365"
|
|
1731
1731
|
},
|
|
1732
1732
|
{
|
|
1733
1733
|
"path": "docs/copy-registry.json",
|
|
1734
1734
|
"mode": "0000644",
|
|
1735
|
-
"sha256": "
|
|
1735
|
+
"sha256": "ed8c0d7c6ab7edd3ae61cd97c530323ed915f32cfa14a4f4ed1bca4d52437a01"
|
|
1736
1736
|
},
|
|
1737
1737
|
{
|
|
1738
1738
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -2577,7 +2577,7 @@
|
|
|
2577
2577
|
{
|
|
2578
2578
|
"path": "docs/module-api-changelog.md",
|
|
2579
2579
|
"mode": "0000644",
|
|
2580
|
-
"sha256": "
|
|
2580
|
+
"sha256": "4af1ded195d83c7060e26bf72c6aa27144bfba92e2ce679e2e90d57b866a0c93"
|
|
2581
2581
|
},
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "docs/modules-contract.md",
|
|
@@ -4487,7 +4487,7 @@
|
|
|
4487
4487
|
{
|
|
4488
4488
|
"path": "modules/hall-ui/public/builders.js",
|
|
4489
4489
|
"mode": "0000644",
|
|
4490
|
-
"sha256": "
|
|
4490
|
+
"sha256": "18e113894ddb8d8e86faa01d20edde978f8710f8eb546e0baa8b7732d46cf00c"
|
|
4491
4491
|
},
|
|
4492
4492
|
{
|
|
4493
4493
|
"path": "modules/hall-ui/public/chime.wav",
|
|
@@ -4707,7 +4707,7 @@
|
|
|
4707
4707
|
{
|
|
4708
4708
|
"path": "modules/hall-ui/public/hall-widgets.js",
|
|
4709
4709
|
"mode": "0000644",
|
|
4710
|
-
"sha256": "
|
|
4710
|
+
"sha256": "6a3a19788c6e39497f32c130086c812fad15a5a7cf01d91b8a68f673b9ea6437"
|
|
4711
4711
|
},
|
|
4712
4712
|
{
|
|
4713
4713
|
"path": "modules/hall-ui/public/harbor.css",
|
|
@@ -5512,7 +5512,7 @@
|
|
|
5512
5512
|
{
|
|
5513
5513
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
5514
5514
|
"mode": "0000644",
|
|
5515
|
-
"sha256": "
|
|
5515
|
+
"sha256": "0ba020342e32eb552532a3a34a57b19aa4bbe87f0cb89ace0c45f4c301d7a015"
|
|
5516
5516
|
},
|
|
5517
5517
|
{
|
|
5518
5518
|
"path": "modules/lifecycle/routes/versions.js",
|
|
@@ -7227,12 +7227,12 @@
|
|
|
7227
7227
|
{
|
|
7228
7228
|
"path": "package-lock.json",
|
|
7229
7229
|
"mode": "0000644",
|
|
7230
|
-
"sha256": "
|
|
7230
|
+
"sha256": "e9270218a7b9b405011ac398777c9eb70fca7f0cdf3010649ea7450f25d399bb"
|
|
7231
7231
|
},
|
|
7232
7232
|
{
|
|
7233
7233
|
"path": "package.json",
|
|
7234
7234
|
"mode": "0000644",
|
|
7235
|
-
"sha256": "
|
|
7235
|
+
"sha256": "6a1614277a1b71103a1882256a82e138cbdb4bfbc28be233e69cc751d7f24eed"
|
|
7236
7236
|
},
|
|
7237
7237
|
{
|
|
7238
7238
|
"path": "public-docs/index.html",
|
|
@@ -8902,7 +8902,7 @@
|
|
|
8902
8902
|
{
|
|
8903
8903
|
"path": "src/module-api.js",
|
|
8904
8904
|
"mode": "0000644",
|
|
8905
|
-
"sha256": "
|
|
8905
|
+
"sha256": "eef34fbd7a43f793b876c07999581137f4e8e37cc0b3f03d7db41759b0271693"
|
|
8906
8906
|
},
|
|
8907
8907
|
{
|
|
8908
8908
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10287,7 +10287,7 @@
|
|
|
10287
10287
|
{
|
|
10288
10288
|
"path": "tests/hall_board_world.mjs",
|
|
10289
10289
|
"mode": "0000644",
|
|
10290
|
-
"sha256": "
|
|
10290
|
+
"sha256": "b0ef3e407d7bd9266231dc089f1288ee40efb113e40333f79d3de4436db6cc21"
|
|
10291
10291
|
},
|
|
10292
10292
|
{
|
|
10293
10293
|
"path": "tests/hall_catalog_world.mjs",
|
|
@@ -10317,7 +10317,7 @@
|
|
|
10317
10317
|
{
|
|
10318
10318
|
"path": "tests/hall_page_gate_map.mjs",
|
|
10319
10319
|
"mode": "0000644",
|
|
10320
|
-
"sha256": "
|
|
10320
|
+
"sha256": "e65e22471469a9bf4551925f0549cfd6720aa7d5b7b7cf9df17984604e07c3cb"
|
|
10321
10321
|
},
|
|
10322
10322
|
{
|
|
10323
10323
|
"path": "tests/hall_palette.mjs",
|
|
@@ -10389,6 +10389,11 @@
|
|
|
10389
10389
|
"mode": "0000644",
|
|
10390
10390
|
"sha256": "e2d482ca08d151719f61b52bcbb8056d5c91c9447b5bba7c9f7682d9582e34b8"
|
|
10391
10391
|
},
|
|
10392
|
+
{
|
|
10393
|
+
"path": "tests/home_bounded_reads.mjs",
|
|
10394
|
+
"mode": "0000644",
|
|
10395
|
+
"sha256": "186a8ebd04cd4dc8021b3a36699468e9c0bcf78ca61ce9f7be7c3e25c632fb3e"
|
|
10396
|
+
},
|
|
10392
10397
|
{
|
|
10393
10398
|
"path": "tests/hook_core_resolve.mjs",
|
|
10394
10399
|
"mode": "0000644",
|
|
@@ -10874,6 +10879,11 @@
|
|
|
10874
10879
|
"mode": "0000644",
|
|
10875
10880
|
"sha256": "079d7eef5828912e306e9dbbe081fef6004526067bd15aa6433dc2ff1873a4be"
|
|
10876
10881
|
},
|
|
10882
|
+
{
|
|
10883
|
+
"path": "tests/my_projects_surface.mjs",
|
|
10884
|
+
"mode": "0000644",
|
|
10885
|
+
"sha256": "03b61cb8de6713b03fe17b2b3c5d70af7c511b33b17a42295f49c36e27771470"
|
|
10886
|
+
},
|
|
10877
10887
|
{
|
|
10878
10888
|
"path": "tests/needs_rebase.mjs",
|
|
10879
10889
|
"mode": "0000644",
|
|
@@ -123,7 +123,7 @@ export interface GetSessionsSearchResponse { sessions: unknown; count: unknown;
|
|
|
123
123
|
export interface GetSsoPubkeyResponse { alg: unknown; format: unknown; public_key_b64: unknown }
|
|
124
124
|
export interface GetTaskRecommendationsForMeResponse { recommendations: unknown; count: unknown }
|
|
125
125
|
export interface GetTaskRecommendationsMineResponse { recommendations: unknown; count: unknown }
|
|
126
|
-
export interface GetTasksClaimableResponse { claimable: unknown; active_claims: unknown }
|
|
126
|
+
export interface GetTasksClaimableResponse { claimable: unknown; claimable_total: unknown; active_claims: unknown }
|
|
127
127
|
export interface GetTasksIdCriteriaResponse { criteria: unknown }
|
|
128
128
|
export interface GetTasksIdDependenciesResponse { dependencies: unknown; dependents: unknown }
|
|
129
129
|
export interface GetTasksIdRecommendationsResponse { recommendations: unknown; count: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -17273,10 +17273,12 @@
|
|
|
17273
17273
|
"type": "object",
|
|
17274
17274
|
"properties": {
|
|
17275
17275
|
"claimable": {},
|
|
17276
|
+
"claimable_total": {},
|
|
17276
17277
|
"active_claims": {}
|
|
17277
17278
|
},
|
|
17278
17279
|
"required": [
|
|
17279
17280
|
"claimable",
|
|
17281
|
+
"claimable_total",
|
|
17280
17282
|
"active_claims"
|
|
17281
17283
|
]
|
|
17282
17284
|
},
|
package/docs/copy-inventory.md
CHANGED
|
@@ -131,7 +131,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
131
131
|
| `daf73127afff` | certain | A word on why | `modules/hall-ui/public/hall-render.js:278` |
|
|
132
132
|
| `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:46` |
|
|
133
133
|
| `996edffc1966` | certain | Access requests | `modules/hall-ui/public/watch.html:61` |
|
|
134
|
-
| `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:
|
|
134
|
+
| `303f8d90266f` | certain | Achievements could not be loaded. | `modules/hall-ui/public/builders.js:1150` |
|
|
135
135
|
| `6ca31dd5eb91` | certain | Acknowledge the primer | `modules/hall-ui/public/primer.html:99` |
|
|
136
136
|
| `4461b13f95c2` | certain | Actions apply at once. Closing stops and deletes the box; you can start a fresh one anytime. | `modules/hall-ui/public/settings.html:209` |
|
|
137
137
|
| `524188fe0078` | certain | Active sessions | `modules/hall-ui/public/settings.html:162` |
|
|
@@ -864,7 +864,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
864
864
|
| `381f29be7497` | certain | The most recently shipped tasks. | `modules/hall-ui/public/work.html:108` |
|
|
865
865
|
| `82479c539fca` | certain | The name shown beside your work — in the roster, the leaderboard, and the work feed. Your sign-in and the cred | `modules/hall-ui/public/settings.html:75` |
|
|
866
866
|
| `b88dd8b96b72` | certain | The next person to notice this sentence reads your note instead of re-litigating it. | `modules/hall-ui/public/copy-desk.html:135` |
|
|
867
|
-
| `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:
|
|
867
|
+
| `aa716f3dd551` | certain | The page could not be loaded. Try again. | `modules/hall-ui/public/builders.js:1392` |
|
|
868
868
|
| `6c3d88de42de` | certain | The primer | `modules/hall-ui/public/primer.html:41` |
|
|
869
869
|
| `dea378cd4837` | certain | The primer did not load | `modules/hall-ui/public/primer.html:88` |
|
|
870
870
|
| `3bc5ec5f35ec` | certain | The ranks, lowest to highest | `modules/hall-ui/public/ranks.html:46` |
|
package/docs/copy-registry.json
CHANGED
|
@@ -2056,7 +2056,7 @@
|
|
|
2056
2056
|
"surface": "builders-hall",
|
|
2057
2057
|
"text": "Achievements could not be loaded.",
|
|
2058
2058
|
"file": "modules/hall-ui/public/builders.js",
|
|
2059
|
-
"line":
|
|
2059
|
+
"line": 1150,
|
|
2060
2060
|
"origin": "js-markup",
|
|
2061
2061
|
"confidence": "certain"
|
|
2062
2062
|
},
|
|
@@ -8653,7 +8653,7 @@
|
|
|
8653
8653
|
"surface": "builders-hall",
|
|
8654
8654
|
"text": "The page could not be loaded. Try again.",
|
|
8655
8655
|
"file": "modules/hall-ui/public/builders.js",
|
|
8656
|
-
"line":
|
|
8656
|
+
"line": 1392,
|
|
8657
8657
|
"origin": "js-notice",
|
|
8658
8658
|
"confidence": "certain"
|
|
8659
8659
|
},
|
|
@@ -1449,5 +1449,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1449
1449
|
landed since 1.19.504 with no explicit bump. run 33785121177. (task 1002620)
|
|
1450
1450
|
1.19.506 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1451
1451
|
landed since 1.19.505 with no explicit bump. run 33787453336. (task 1002620)
|
|
1452
|
+
1.19.507 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1453
|
+
landed since 1.19.506 with no explicit bump. run 33788726591. (task 1002620)
|
|
1454
|
+
1.19.508 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1455
|
+
landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
|
|
1452
1456
|
---------------------------------------------------------------------------
|
|
1453
1457
|
```
|
|
@@ -866,7 +866,10 @@
|
|
|
866
866
|
let canTriage = false;
|
|
867
867
|
function loadTriageInbox() {
|
|
868
868
|
if (!canTriage) return;
|
|
869
|
-
|
|
869
|
+
// ?limit=5 — the card renders five rows; the route's default is 50, and the
|
|
870
|
+
// open_count that drives the "N waiting" line is a separate count, so asking for
|
|
871
|
+
// what is rendered costs the reader nothing (goal 1000072).
|
|
872
|
+
getJSON(`${API}/inbox?limit=5`)
|
|
870
873
|
.then((data) => renderTriageInbox(data))
|
|
871
874
|
.catch(() => { /* nice-to-have; never break the hall */ });
|
|
872
875
|
}
|
|
@@ -895,13 +898,28 @@
|
|
|
895
898
|
const subhead = document.getElementById('home-recent-subh');
|
|
896
899
|
if (!wrap || !list) return;
|
|
897
900
|
try {
|
|
898
|
-
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
|
|
901
|
+
// ?limit=2 — this card is TWO lines. It used to download the entire claimable
|
|
902
|
+
// feed, every row with its full description, and `.slice(0, 2)` it: the same
|
|
903
|
+
// shape goal 1000072's criterion names, and the one goal-rollup.js was carved to
|
|
904
|
+
// fix on the Goals page (5,229,455 bytes to produce forty counters). The server
|
|
905
|
+
// already orders by priority then calibrated estimate, so the first two rows ARE
|
|
906
|
+
// the top two. `claimable_total` comes back so the foot can say how many are
|
|
907
|
+
// waiting instead of leaving two rows to imply there are only two.
|
|
908
|
+
const resp = await getJSON(`${API}/tasks/claimable?limit=2`, { softAuth: true });
|
|
909
|
+
// task 1003464: the greeting's "N tasks are claimable right now" — rank-scoped,
|
|
910
|
+
// so it is THIS builder's number. It reads claimable_total rather than the length
|
|
911
|
+
// of a fully-downloaded feed: the same number, without the wire cost that this
|
|
912
|
+
// commit exists to remove. The .length fallback covers a server that predates
|
|
913
|
+
// the field (a deploy skew), so the greeting degrades to correct-but-costly
|
|
914
|
+
// rather than to blank.
|
|
915
|
+
const claimableTotal = Number(resp && resp.claimable_total);
|
|
916
|
+
lastClaimableCount = Number.isFinite(claimableTotal)
|
|
917
|
+
? claimableTotal
|
|
918
|
+
: (resp && Array.isArray(resp.claimable) ? resp.claimable.length : null);
|
|
902
919
|
renderGreeting();
|
|
903
|
-
const rows =
|
|
920
|
+
const rows = resp && Array.isArray(resp.claimable) ? resp.claimable : [];
|
|
904
921
|
if (!rows.length) { wrap.hidden = true; if (subhead) subhead.hidden = true; return; }
|
|
922
|
+
const total = Number(resp.claimable_total);
|
|
905
923
|
const cur = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
|
|
906
924
|
list.innerHTML = rows.map((t) => {
|
|
907
925
|
const credits = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
|
|
@@ -911,6 +929,15 @@
|
|
|
911
929
|
<span class="next-up__meta">#${escapeHtml(String(t.id))}${mins} · ${fmtNum(credits)} ${escapeHtml(cur)} est.</span>
|
|
912
930
|
</li>`;
|
|
913
931
|
}).join('');
|
|
932
|
+
// Name the size of the queue these two came off. Silent when the count is
|
|
933
|
+
// missing or not bigger than what is shown — "See all 2" under two rows is
|
|
934
|
+
// noise, and a made-up number is worse than none.
|
|
935
|
+
const foot = document.getElementById('home-next-foot');
|
|
936
|
+
if (foot) {
|
|
937
|
+
foot.textContent = Number.isFinite(total) && total > rows.length
|
|
938
|
+
? `See all ${fmtNum(total)} ready work →`
|
|
939
|
+
: 'See all ready work →';
|
|
940
|
+
}
|
|
914
941
|
wrap.hidden = false;
|
|
915
942
|
if (subhead) subhead.hidden = false;
|
|
916
943
|
} catch (_) {
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
'<div id="home-next" hidden>' +
|
|
103
103
|
'<h3 class="ginbox__sec-h">Next up — ready to claim</h3>' +
|
|
104
104
|
'<ul class="next-up" id="home-next-list"></ul>' +
|
|
105
|
-
'<p class="next-up__foot"><a href="/work">See all ready work →</a></p>' +
|
|
105
|
+
'<p class="next-up__foot"><a href="/work" id="home-next-foot">See all ready work →</a></p>' +
|
|
106
106
|
'</div>' +
|
|
107
107
|
'<h3 class="ginbox__sec-h" id="home-recent-subh" hidden>Recently shipped</h3>' +
|
|
108
108
|
'<table class="ledger" id="home-recent-table"><thead><tr><th>№</th><th>Work</th><th data-currency-label="caps">Credits</th><th>Shipped</th></tr></thead>' +
|
|
@@ -161,7 +161,12 @@ module.exports = function buildTasksRouter() {
|
|
|
161
161
|
if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
162
162
|
const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
|
|
163
163
|
if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
164
|
-
|
|
164
|
+
// ?limit=N — opt-in, for a caller that renders N rows (goal 1000072; the hall's
|
|
165
|
+
// two-line Home card was pulling this whole feed, descriptions and all). It slices
|
|
166
|
+
// the RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
|
|
167
|
+
const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
|
|
168
|
+
if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
|
|
169
|
+
const [feed, active] = await Promise.all([
|
|
165
170
|
db.listClaimableTasks({
|
|
166
171
|
versionId: version || null,
|
|
167
172
|
builderId: req.builder.id,
|
|
@@ -172,13 +177,17 @@ module.exports = function buildTasksRouter() {
|
|
|
172
177
|
}),
|
|
173
178
|
db.listActiveClaims(),
|
|
174
179
|
]);
|
|
180
|
+
const claimable = claimLimit ? feed.slice(0, claimLimit) : feed;
|
|
175
181
|
// V3.R75 (#294): tick the onboarding state machine for first_start. Fire
|
|
176
182
|
// and forget — the response should never wait on or fail because of the
|
|
177
183
|
// onboarding write. safeMarkStage swallows errors and handles the
|
|
178
184
|
// graduation broadcast. Through the `onboarding` kernel port (BV1.R81 / ADR
|
|
179
185
|
// 0093 §1); ?. degrades to a no-op when the module is off.
|
|
180
186
|
seams.resolveOptional('onboarding')?.safeMarkStage(pool, req.builder.id, 'first_start', req.builder);
|
|
181
|
-
|
|
187
|
+
// `claimable_total` is what makes the limit honest — two rows with no count reads
|
|
188
|
+
// as "there are only two". It is the length of the array the slice came from, never
|
|
189
|
+
// a second count query (that is how a total and its own list drift apart, ADR 0197).
|
|
190
|
+
res.json({ claimable, claimable_total: feed.length, active_claims: active });
|
|
182
191
|
});
|
|
183
192
|
|
|
184
193
|
// GET /tasks/newcomer-floor — supply gauge for the newcomer queue (task 614).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.508",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.508",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.508",
|
|
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/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.508'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -294,8 +294,18 @@ test('the system notices are inbox LINES, first in the card, and counted by its
|
|
|
294
294
|
|
|
295
295
|
test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
|
|
296
296
|
assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
|
|
297
|
-
|
|
297
|
+
// The count comes off the SAME Next-up response — one request, not a second
|
|
298
|
+
// fetch. It reads `claimable_total` rather than the length of the returned array,
|
|
299
|
+
// because that request is now bounded (?limit=2, task 1003545): the array holds two
|
|
300
|
+
// rows and the total holds the whole rank-scoped queue. Asserting the derivation
|
|
301
|
+
// rather than one literal expression is deliberate — the previous version pinned
|
|
302
|
+
// `resp.claimable.length`, which read as a requirement to download the feed.
|
|
303
|
+
assert.match(buildersJs, /lastClaimableCount = Number\.isFinite\(claimableTotal\)/,
|
|
298
304
|
'the claimable feed Home already fetches for Next-up answers the count');
|
|
305
|
+
assert.match(buildersJs, /const claimableTotal = Number\(resp && resp\.claimable_total\);/,
|
|
306
|
+
'and it takes the count from the response total, so a bounded read still knows it');
|
|
307
|
+
assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
|
|
308
|
+
'no unbounded claimable fetch on Home — the count must never cost the whole queue');
|
|
299
309
|
assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
|
|
300
310
|
assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
|
|
301
311
|
assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
|
|
@@ -47,6 +47,13 @@ const ORACLE = {
|
|
|
47
47
|
'/builders/modules': 'requireBuilderPage',
|
|
48
48
|
'/builders/goals': 'requireBuilderPage',
|
|
49
49
|
'/builders/roadmap': 'requireBuilderPage',
|
|
50
|
+
// Gated at this tier all along, but with no row until task 1003448 walked the
|
|
51
|
+
// module's pages: copy-desk (task 1003113) and ideas (task 1002928) are open to
|
|
52
|
+
// every rank by design, and collab's reads (task 1003172) are scoped to the
|
|
53
|
+
// caller by the server — so for all three, sign-in IS the whole gate.
|
|
54
|
+
'/builders/copy-desk': 'requireBuilderPage',
|
|
55
|
+
'/builders/ideas': 'requireBuilderPage',
|
|
56
|
+
'/builders/collab': 'requireBuilderPage',
|
|
50
57
|
// task 1002710: the orientation reading rooms moved DOWN to this tier from
|
|
51
58
|
// requireNonXenosPage (thetes+). They are the onboarding documents, so gating
|
|
52
59
|
// them above xenos turned away the exact reader they were written for. The
|
|
@@ -92,10 +99,33 @@ const ORACLE = {
|
|
|
92
99
|
'/builders/drachmae': null,
|
|
93
100
|
'/builders/profile': null,
|
|
94
101
|
'/builders/not-ready': null,
|
|
102
|
+
|
|
103
|
+
// NOT deliberate — recorded because it is true, and filed as idea 1000907
|
|
104
|
+
// (task 1003448). The detail pages are addressed by a second segment, and
|
|
105
|
+
// /builders/task/:id and /builders/idea/:id are both gated at signed-in; the
|
|
106
|
+
// id-less slugs still resolve to the same shells through BUILDERS_PAGE_RE and
|
|
107
|
+
// match no gate. No data rides the shell and every read behind it is gated
|
|
108
|
+
// server-side, so this is an inconsistency rather than an exposure — but it is
|
|
109
|
+
// a row that should turn into a real gate (or a redirect) when that is decided.
|
|
110
|
+
'/builders/task': null,
|
|
111
|
+
'/builders/idea': null,
|
|
112
|
+
|
|
113
|
+
// Gone, not public — the hall ships no my-projects page at all since task
|
|
114
|
+
// 1003308 (owner's decision: "my projects" is a user-level page the hub owns,
|
|
115
|
+
// so the hall ships no page, no link and no redirect). Nothing is served here,
|
|
116
|
+
// so there is nothing to gate. Recorded rather than left out, because "absent
|
|
117
|
+
// from the map" is indistinguishable from the oversight this file exists to
|
|
118
|
+
// catch. GONE_PAGES below keeps the row honest if the file ever returns.
|
|
119
|
+
'/builders/my-projects': null,
|
|
95
120
|
};
|
|
96
121
|
|
|
97
122
|
// ─── reconstruct the URL→gate map from the source ───────────────────────────
|
|
98
123
|
// 1. every `const NAME_RE = /…/;` (the page-matching regexes)
|
|
124
|
+
// The eval below turns a matched regex LITERAL back into a RegExp. Its only
|
|
125
|
+
// input is this repo's own serve-internal.js, read from a path fixed at the top
|
|
126
|
+
// of this file — never a caller's string, never anything fetched. Keep it that
|
|
127
|
+
// way: sourcing SRC from anywhere an outsider can influence makes this arbitrary
|
|
128
|
+
// code execution rather than a parser.
|
|
99
129
|
function readRegexes(src) {
|
|
100
130
|
const out = new Map();
|
|
101
131
|
const re = /const\s+([A-Z0-9_]+_RE)\s*=\s*(\/(?:\\.|\[(?:\\.|[^\]])*\]|[^/\\])+\/[gimsuy]*)\s*;/g;
|
|
@@ -170,6 +200,47 @@ test('path-normalization tricks do not slip past a gate', () => {
|
|
|
170
200
|
}
|
|
171
201
|
});
|
|
172
202
|
|
|
203
|
+
// ═══ exhaustiveness — the reason a page can go missing from the oracle ═══════
|
|
204
|
+
// The ORACLE above is hand-written, so until now a page could ship with NO gate
|
|
205
|
+
// and NO row and nothing would notice — the same blind spot that let /sessions
|
|
206
|
+
// and /scouting ship ungated, and that left /my-projects out of the map entirely
|
|
207
|
+
// (task 1003448). Walking the module's own pages turns "someone remembered to
|
|
208
|
+
// add a row" into "the tree is walked", which is the half this test was missing.
|
|
209
|
+
const HALL_PAGES_DIR = path.join(ROOT, 'modules/hall-ui/public');
|
|
210
|
+
|
|
211
|
+
function hallPageUrls() {
|
|
212
|
+
return fs.readdirSync(HALL_PAGES_DIR)
|
|
213
|
+
.filter((f) => f.endsWith('.html'))
|
|
214
|
+
.map((f) => (f === 'index.html' ? '/builders/' : `/builders/${f.replace(/\.html$/, '')}`));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
test('every hall page the module ships has a row in the oracle', () => {
|
|
218
|
+
const missing = hallPageUrls().filter((url) => !(url in ORACLE));
|
|
219
|
+
assert.deepEqual(missing, [],
|
|
220
|
+
`hall pages with no ORACLE row: ${missing.join(', ')} — give each a gate, or a deliberate null with the reason`);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// A page can also LEAVE the hall, and then `null` claims something weaker than
|
|
224
|
+
// "the shell is public": nothing is served at that URL at all. Spelling those out
|
|
225
|
+
// keeps the row honest — if the file ever comes back, the null stops describing
|
|
226
|
+
// reality, and this fails instead of quietly waving an ungated shell through.
|
|
227
|
+
const GONE_PAGES = {
|
|
228
|
+
// Removed by an owner decision in task 1003308 — "my projects" is a user-level
|
|
229
|
+
// page the hub already owns, so the hall ships no page, no link and no
|
|
230
|
+
// redirect. tests/hall_settings_world.mjs pins the deletion itself; this row
|
|
231
|
+
// pins what the deletion means for the gate map.
|
|
232
|
+
'/builders/my-projects': 'task 1003308 — the hub owns "my projects"',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
test('a row that is null because the page left the hall serves no page', () => {
|
|
236
|
+
for (const [url, why] of Object.entries(GONE_PAGES)) {
|
|
237
|
+
assert.equal(ORACLE[url], null, `${url} is gone (${why}), so it can carry no gate`);
|
|
238
|
+
const file = path.join(HALL_PAGES_DIR, `${url.replace('/builders/', '')}.html`);
|
|
239
|
+
assert.ok(!fs.existsSync(file),
|
|
240
|
+
`${url} is back, so "${why}" no longer holds — give it a real gate and a real row`);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
173
244
|
test('no gated page is left matching only a REMOVED gate function', () => {
|
|
174
245
|
// requireNonXenosPage still EXISTS in auth.js (page.view.builder is a live
|
|
175
246
|
// catalog atom an Archon can attach to a custom role) but task 1002710 left it
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// tests/home_bounded_reads.mjs — the Home card asks for what it renders
|
|
2
|
+
// (goal 1000072, "Human legibility").
|
|
3
|
+
//
|
|
4
|
+
// THE PATTERN THIS CLOSES. This goal's criterion `legibility-sort-and-staleness` set
|
|
5
|
+
// the rule in its own words — "~1,357 tasks on BONGOS-V1 alone. A view that does not
|
|
6
|
+
// survive that number is not a solution" — and `modules/lifecycle/goal-rollup.js`
|
|
7
|
+
// exists because the Goals page broke it: **5,229,455 bytes for ~1,400 tasks, measured
|
|
8
|
+
// 2026-08-25, to produce roughly forty small counter objects.**
|
|
9
|
+
//
|
|
10
|
+
// The same shape was still live on the page every builder lands on first. Home's
|
|
11
|
+
// "Next up" card called `GET /tasks/claimable` — no limit, `SELECT v.*`, every
|
|
12
|
+
// description on every claimable row — and then `.slice(0, 2)` to fill TWO lines. Its
|
|
13
|
+
// triage card pulled the route's default fifty ideas to render five.
|
|
14
|
+
//
|
|
15
|
+
// So this pins the rule rather than the two instances: a Home card may not download a
|
|
16
|
+
// feed and throw most of it away, and the limit it asks for must come back with a
|
|
17
|
+
// total, or two rows are indistinguishable from "there are only two".
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/home_bounded_reads.mjs
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { makeRunner } from './helpers.mjs';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
30
|
+
const BUILDERS_JS = read('modules/hall-ui/public/builders.js');
|
|
31
|
+
const WIDGETS_JS = read('modules/hall-ui/public/hall-widgets.js');
|
|
32
|
+
const ROUTE = read('modules/lifecycle/routes/tasks.js');
|
|
33
|
+
const DB_TASKS = read('modules/lifecycle/db-tasks.js');
|
|
34
|
+
|
|
35
|
+
// Comments stripped before any "is the bad pattern gone" assertion — the same rule
|
|
36
|
+
// scripts/gds/category-advisory-guard.js applies. A comment EXPLAINING the pattern that
|
|
37
|
+
// was removed is documentation; matching it would make the fix un-documentable.
|
|
38
|
+
const noComments = (src) => src.replace(/^\s*\/\/.*$/gm, '');
|
|
39
|
+
|
|
40
|
+
const { test, summary } = makeRunner();
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// 1. the rule, over Home's own boot reads
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
await test('no Home read downloads a feed and slices it in the browser', () => {
|
|
47
|
+
// The tell is a `.slice(0, N)` applied to a response body. Narrowing what is
|
|
48
|
+
// DISPLAYED is fine; narrowing what was FETCHED is the bug, and the two look
|
|
49
|
+
// identical at the call site — which is why this is a test and not a code review.
|
|
50
|
+
const src = noComments(BUILDERS_JS);
|
|
51
|
+
for (const fn of ['loadNextUp', 'loadTriageInbox']) {
|
|
52
|
+
const from = src.indexOf(`function ${fn}(`);
|
|
53
|
+
assert.ok(from > 0, `${fn} must exist`);
|
|
54
|
+
const body = src.slice(from, src.indexOf('\n }\n', from));
|
|
55
|
+
const name = fn;
|
|
56
|
+
const url = body.match(/\$\{API\}\/[^`'"]+/);
|
|
57
|
+
assert.ok(url, `${name} must issue a request`);
|
|
58
|
+
assert.match(url[0], /[?&]limit=\d+/, `${name} must ask the server for its page size`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('the two cards ask for exactly the rows they draw', () => {
|
|
63
|
+
assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=2/, 'Next up draws two rows');
|
|
64
|
+
assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
|
|
65
|
+
// …and no longer re-slices what came back.
|
|
66
|
+
const src = noComments(BUILDERS_JS);
|
|
67
|
+
const from = src.indexOf('function loadNextUp(');
|
|
68
|
+
const body = src.slice(from, src.indexOf('\n }\n', from));
|
|
69
|
+
assert.ok(body.length > 0, 'loadNextUp body found');
|
|
70
|
+
assert.doesNotMatch(body, /\.slice\(0,\s*2\)/, 'the client-side slice is gone');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 2. the limit is honest — it comes back with a total
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
await test('GET /tasks/claimable validates ?limit and answers with a total', () => {
|
|
78
|
+
assert.match(ROUTE, /posInt\(req\.query\.limit, 'bad_limit', 1000\)/, 'same validator as GET /tasks');
|
|
79
|
+
assert.match(ROUTE, /res\.fail\('bad_limit'/);
|
|
80
|
+
assert.match(ROUTE, /claimable_total: feed\.length/);
|
|
81
|
+
// The total must be the length of the very array the rows were sliced from. A
|
|
82
|
+
// companion count query would be a second copy of a filter chain that runs half in
|
|
83
|
+
// SQL and half in JS — the ADR 0197 failure, one release later.
|
|
84
|
+
assert.doesNotMatch(ROUTE, /countClaimableTasks/,
|
|
85
|
+
'no second count query: the sliced array reports its own length');
|
|
86
|
+
assert.match(ROUTE, /const claimable = claimLimit \? feed\.slice\(0, claimLimit\) : feed;/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('the limit is OPT-IN — the Work Board and /builder-start are untouched', () => {
|
|
90
|
+
// Their whole job is the full feed. A default limit would silently truncate the board
|
|
91
|
+
// and the CLI's claim menu, which is a worse bug than the one being fixed. `posInt`
|
|
92
|
+
// returns undefined for an absent param, so `claimLimit ? … : feed` hands back
|
|
93
|
+
// everything exactly as before.
|
|
94
|
+
assert.match(ROUTE, /claimLimit \? feed\.slice/);
|
|
95
|
+
assert.doesNotMatch(DB_TASKS, /listClaimableTasks\([^)]*limit/,
|
|
96
|
+
'the db helper gained no limit — nothing below the route changed');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await test('every db.* the claimable handler calls actually exists', () => {
|
|
100
|
+
// The bug this catches, caught the hard way: an earlier cut pushed the limit into the
|
|
101
|
+
// db layer, was reverted, and left the route calling `db.listClaimableTasksPaged` — a
|
|
102
|
+
// function that no longer existed. Every assertion in this file is source text, and
|
|
103
|
+
// source text cannot see a missing symbol; a neighbouring suite found it instead.
|
|
104
|
+
const from = ROUTE.indexOf("router.get('/tasks/claimable'");
|
|
105
|
+
const body = ROUTE.slice(from, ROUTE.indexOf('res.json({ claimable', from));
|
|
106
|
+
const called = [...new Set([...body.matchAll(/\bdb\.([A-Za-z0-9_]+)\s*\(/g)].map((m) => m[1]))];
|
|
107
|
+
assert.ok(called.length > 0, 'the handler calls the db layer');
|
|
108
|
+
const dbExports = read('modules/lifecycle/db.js');
|
|
109
|
+
const block = dbExports.slice(dbExports.lastIndexOf('module.exports'));
|
|
110
|
+
for (const fn of called) {
|
|
111
|
+
assert.ok(new RegExp(`\\b${fn}\\b`).test(block), `db.js must export ${fn} — the route calls it`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('the db layer is untouched: no new export, no second feed function', () => {
|
|
116
|
+
// The first cut pushed the limit into db-tasks.js and exported a paged twin. It
|
|
117
|
+
// bought nothing — a SQL LIMIT is unsafe here either way, so the query does the same
|
|
118
|
+
// work — and it widened the hand-mirrored export lists in db.js/db-tasks.js until
|
|
119
|
+
// they shared a 30-line run and tripped the duplication ratchet. The wire is the
|
|
120
|
+
// thing being saved, and the route is where the wire is.
|
|
121
|
+
assert.doesNotMatch(DB_TASKS, /listClaimableTasksPaged/);
|
|
122
|
+
assert.doesNotMatch(read('modules/lifecycle/db.js'), /listClaimableTasksPaged/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// 3. the trap a SQL LIMIT would have walked into
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
await test('the limit is applied AFTER the JS post-filters, not as a SQL LIMIT', () => {
|
|
130
|
+
// listClaimableTasks filters twice in JS after the query returns: the autonomous
|
|
131
|
+
// runner's protected-path + membership drops, and capNewcomerTasks' display cap.
|
|
132
|
+
// `LIMIT 2` in SQL can hand back two rows that both get dropped, leaving an empty
|
|
133
|
+
// feed with claimable work sitting right behind it. Slicing the RETURNED feed is
|
|
134
|
+
// what makes the limit safe, and it is why the query is unchanged.
|
|
135
|
+
const fn = DB_TASKS.slice(
|
|
136
|
+
DB_TASKS.indexOf('async function listClaimableTasks('),
|
|
137
|
+
DB_TASKS.indexOf('function taskTouchesProtectedPath(')
|
|
138
|
+
);
|
|
139
|
+
assert.ok(fn.length > 0, 'found listClaimableTasks');
|
|
140
|
+
assert.doesNotMatch(fn, /LIMIT \$/, 'no SQL LIMIT in the claimable query');
|
|
141
|
+
assert.match(fn, /return capNewcomerTasks\(rows\);/, 'the JS post-filters still have the last word');
|
|
142
|
+
const routeFrom = ROUTE.indexOf("router.get('/tasks/claimable'");
|
|
143
|
+
const routeBody = ROUTE.slice(routeFrom, ROUTE.indexOf('res.json({ claimable', routeFrom));
|
|
144
|
+
assert.match(routeBody, /feed\.slice\(0, claimLimit\)/, 'the slice happens on the finished feed');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// 4. what the reader sees
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
await test('the foot names the queue the two rows came off', () => {
|
|
152
|
+
assert.match(WIDGETS_JS, /id="home-next-foot"/, 'the link has an id to write into');
|
|
153
|
+
assert.match(BUILDERS_JS, /See all \$\{fmtNum\(total\)\} ready work/);
|
|
154
|
+
// Silent rather than wrong when the count is missing or not larger than what is
|
|
155
|
+
// shown: "See all 2" under two rows is noise, and a guessed number is worse.
|
|
156
|
+
assert.match(BUILDERS_JS, /Number\.isFinite\(total\) && total > rows\.length/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
summary();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/my_projects_surface.mjs
|
|
2
|
+
//
|
|
3
|
+
// GUARD (task 1003448): pins the three answers the "/my-projects is unreachable"
|
|
4
|
+
// audit finding actually has, so that none of them is a claim in a session log
|
|
5
|
+
// that the next reader has to take on faith.
|
|
6
|
+
//
|
|
7
|
+
// The audit (2026-08-30) reported the hall's my-projects page as unreachable and
|
|
8
|
+
// ungated, and flagged the second half as "NOT CONFIRMED AS A LEAK — check the
|
|
9
|
+
// API gates first, then decide whether the fix is a gate, a nav entry, or both".
|
|
10
|
+
// By the time the task was worked, the answer to "a gate or a nav entry" had
|
|
11
|
+
// become "neither":
|
|
12
|
+
//
|
|
13
|
+
// 1. WHERE THE PAGE WENT. Task 1003308 deleted it on an owner decision — "my
|
|
14
|
+
// projects" is a user-level page the hub already owns, so the hall ships no
|
|
15
|
+
// page, no link and no redirect. A nav entry would reverse that decision and
|
|
16
|
+
// a page gate would guard a URL that resolves to no file. (The deletion
|
|
17
|
+
// itself is pinned by tests/hall_settings_world.mjs; the gate map's row for
|
|
18
|
+
// it by tests/hall_page_gate_map.mjs.)
|
|
19
|
+
// 2. WHETHER ANYTHING LEAKED. No: every /my-projects* route is sign-in-gated
|
|
20
|
+
// and reads the caller's OWN identity off the session. This is the half the
|
|
21
|
+
// audit could not confirm, so it is asserted here rather than described.
|
|
22
|
+
// 3. WHAT IS STILL MISSING. The invitations half has no caller anywhere — no
|
|
23
|
+
// surface fetches the invite list, so a builder holding an invitation still
|
|
24
|
+
// cannot act on it. That is tracked work (task 1002970, idea 1000903), and
|
|
25
|
+
// pinning it keeps a known gap from decaying back into an unknown one.
|
|
26
|
+
//
|
|
27
|
+
// Static reads only (no import of the app, no DB) → runs in the `unit` gate.
|
|
28
|
+
//
|
|
29
|
+
// Run: node --test --test-reporter=tap tests/my_projects_surface.mjs
|
|
30
|
+
|
|
31
|
+
import { test } from 'node:test';
|
|
32
|
+
import assert from 'node:assert/strict';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
39
|
+
|
|
40
|
+
const ROUTES = 'modules/platform-identity/routes/my-projects.js';
|
|
41
|
+
|
|
42
|
+
// ── 1. the page is gone from the hall, by decision ──────────────────────────
|
|
43
|
+
// Kept to the ONE fact this file's other assertions lean on: there is no hall
|
|
44
|
+
// page, so "unreachable from the hall" is a decision to honour and not a defect
|
|
45
|
+
// to fix. tests/hall_settings_world.mjs owns the full deletion pin.
|
|
46
|
+
test('the hall ships no my-projects page, so there is nothing to link or gate', () => {
|
|
47
|
+
const dir = path.join(ROOT, 'modules/hall-ui/public');
|
|
48
|
+
const strays = fs.readdirSync(dir).filter((f) => f.startsWith('my-projects'));
|
|
49
|
+
assert.deepEqual(strays, [],
|
|
50
|
+
'task 1003308 removed the hall page (the hub owns "my projects") — if it is back, that decision changed and this file, the nav and the page gate all need revisiting');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── 2. the "not confirmed as a leak" half, confirmed ────────────────────────
|
|
54
|
+
// The audit's worry was a shell served to anyone who asked. The shell is gone,
|
|
55
|
+
// but the routes behind it outlived it, so the gate is what matters now.
|
|
56
|
+
const MY_PROJECTS_ROUTES = [
|
|
57
|
+
"router.get('/my-projects'",
|
|
58
|
+
"router.post('/my-projects/join'",
|
|
59
|
+
"router.get('/my-projects/invites'",
|
|
60
|
+
"router.post('/my-projects/invites/:clientId/accept'",
|
|
61
|
+
"router.post('/my-projects/invites/:clientId/decline'",
|
|
62
|
+
"router.post('/my-projects/:clientId/leave'",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
test('every /my-projects route is sign-in gated', () => {
|
|
66
|
+
const src = read(ROUTES);
|
|
67
|
+
for (const decl of MY_PROJECTS_ROUTES) {
|
|
68
|
+
const at = src.indexOf(decl);
|
|
69
|
+
assert.notEqual(at, -1, `${decl} — route missing; this list is stale`);
|
|
70
|
+
// The gate is the argument immediately after the path, so read the rest of
|
|
71
|
+
// that line rather than the whole file: a requireBuilder somewhere else in
|
|
72
|
+
// the module must not vouch for a route that carries no gate of its own.
|
|
73
|
+
const line = src.slice(at, src.indexOf('\n', at));
|
|
74
|
+
assert.match(line, /,\s*requireBuilder\s*,/,
|
|
75
|
+
`${decl} must carry requireBuilder — an ungated route here exposes cross-project membership`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('every /my-projects handler reads the caller identity from the session', () => {
|
|
80
|
+
// The gate only proves SOMEONE is signed in. What keeps one builder out of
|
|
81
|
+
// another's projects is that the github_id comes off req.builder and is never
|
|
82
|
+
// accepted from the caller — no :githubId param, no body-supplied account.
|
|
83
|
+
const src = read(ROUTES);
|
|
84
|
+
assert.ok(src.includes('req.builder.github_id'), 'identity must come off the session');
|
|
85
|
+
assert.doesNotMatch(src, /\/my-projects[^']*:githubId/,
|
|
86
|
+
'no /my-projects route may take an account id as a path parameter');
|
|
87
|
+
assert.doesNotMatch(src, /githubId:\s*(?:req\.body|req\.query)/,
|
|
88
|
+
'no /my-projects handler may take the account id from the request body or query');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── 3. the gap that is left, named and tracked ─────────────────────────────
|
|
92
|
+
test('the invitations API still has no caller — the tracked gap, pinned', () => {
|
|
93
|
+
// Deliberately asserts a MISSING feature. Task 1003308 removed the only
|
|
94
|
+
// surface that fetched invitations, so a builder who holds one has nowhere to
|
|
95
|
+
// accept or decline it. Task 1002970 (blocked on task 1002969) and idea
|
|
96
|
+
// 1000903 own the replacement; this pin exists so the gap stays visible until
|
|
97
|
+
// one of them lands, and so it is impossible to close this quietly.
|
|
98
|
+
const surfaces = [];
|
|
99
|
+
const modules = path.join(ROOT, 'modules');
|
|
100
|
+
for (const key of fs.readdirSync(modules)) {
|
|
101
|
+
const pub = path.join(modules, key, 'public');
|
|
102
|
+
if (!fs.existsSync(pub)) continue;
|
|
103
|
+
for (const f of fs.readdirSync(pub)) {
|
|
104
|
+
if (!/\.(js|html)$/.test(f)) continue;
|
|
105
|
+
if (fs.readFileSync(path.join(pub, f), 'utf8').includes('/my-projects/invites')) {
|
|
106
|
+
surfaces.push(`modules/${key}/public/${f}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
assert.deepEqual(surfaces, [],
|
|
111
|
+
`an invitations surface exists now (${surfaces.join(', ')}) — good: delete this pin and close task 1002970 / idea 1000903`);
|
|
112
|
+
});
|