@bongos/core 1.23.19 → 1.23.20
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 +62 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +107 -101
- package/docs/copy-registry.json +189 -125
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +3 -1
- package/docs/page-readings.json +73 -58
- package/modules/platform-identity/builder-of-the-week.js +18 -9
- package/modules/platform-identity/community-leaderboard.js +3 -0
- package/modules/platform-identity/community-movers.js +152 -0
- package/modules/platform-identity/routes/community-leaderboard.js +22 -2
- package/modules/public-landing/public/community-movers-none.states.json +15 -0
- package/modules/public-landing/public/community.html +62 -4
- package/modules/public-landing/public/community.states.json +1 -1
- package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/community_movers.mjs +280 -0
- package/tests/community_movers_db.mjs +147 -0
- package/tests/hub_community_page.mjs +78 -9
- package/tests/privacy_matrix.mjs +48 -9
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The community home with no movers to show (task 1002983; fixtures/community-movers-none.json): the row under the crowns stands with one line in each card.",
|
|
3
|
+
"page": "/community",
|
|
4
|
+
"surface": "public-landing",
|
|
5
|
+
"modes": ["dark"],
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"stub": { "fixtures": { "community-movers": "community-movers-none" } },
|
|
8
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/me/guild-", "/scouting$"],
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
|
|
10
|
+
"ignoreControls": [".vh"],
|
|
11
|
+
"states": {
|
|
12
|
+
"out-home-movers-none": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#homeMovers", "#moveRising .commMsg", "#moveNone", "#moversRule"], "count": [["#moveProjects .commRow", 0]], "text": [["#moveRising .commMsg", "No rising builder in the last 30 days."], ["#moveNone", "No public project shipped a work in the last 30 days."]] } }
|
|
13
|
+
},
|
|
14
|
+
"reducedMotion": { "auth": false, "actions": [["wait", 400]] }
|
|
15
|
+
}
|
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
spec docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8). Who is building, across every
|
|
8
8
|
project, as five sub-pages of the bar's Community menu (owner, 2026-10-27), all served
|
|
9
9
|
from this one file: /community is the community home (task 1004902: the builders of the
|
|
10
|
-
week, month and year,
|
|
10
|
+
week, month and year, under them the rising builder and the projects on the move (task
|
|
11
|
+
1002983), and a door to each page), /community/leaderboard the leaderboard,
|
|
11
12
|
/community/week the builder of the week (task 1002981, spec D5), /community/find the
|
|
12
13
|
builder search, /community/recruiters the builders who hide their numbers but are open
|
|
13
14
|
to recruiters.
|
|
14
15
|
The script shows the section the path names and marks it current in the menu.
|
|
15
16
|
VIEWING IS UNGATED (ADR 0217 D1): the home, the leaderboard, the builder of the week and
|
|
16
|
-
the search are the same for every viewer, signed in or not. All
|
|
17
|
+
the search are the same for every viewer, signed in or not. All five reads are public,
|
|
17
18
|
viewer-independent routes (modules/platform-identity/routes/community-leaderboard.js,
|
|
18
|
-
which also serves the builder of the week and the
|
|
19
|
+
which also serves the builder of the week, the crowns and the movers, and
|
|
20
|
+
community-search.js),
|
|
19
21
|
which decide WHO appears through account-visibility.js; these pages add no rule of
|
|
20
22
|
their own. The one viewer-relative page is the recruiter list: drawn only from a 200 of
|
|
21
23
|
GET /scouting, whose door (a project owner, or project.curate) is the server's, never
|
|
@@ -126,8 +128,18 @@
|
|
|
126
128
|
.homeLast strong{font-weight:500;color:var(--ink);}
|
|
127
129
|
.homeDoors{list-style:none;display:flex;flex-wrap:wrap;gap:10px 12px;margin:10px 0 0;padding:0;}
|
|
128
130
|
.homeDoors .btn{text-transform:none;}
|
|
131
|
+
/* the row under the crowns (task 1002983): the rising builder in the first column, the
|
|
132
|
+
projects on the move across the other two as board rows with no face */
|
|
133
|
+
.homeMovers{margin-top:14px;}
|
|
134
|
+
.homeMovers .homeWide{grid-column:span 2;align-items:stretch;}
|
|
135
|
+
.moveRows{margin:0;}
|
|
136
|
+
.moveRows .commRow{grid-template-columns:3ch minmax(0,1fr) auto;padding:10px 0;}
|
|
137
|
+
.moveRows a{display:inline-block;min-height:24px;padding:3px 0;line-height:18px;color:var(--ink);
|
|
138
|
+
text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:4px;}
|
|
139
|
+
.moveRows a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;}
|
|
129
140
|
@media (max-width:760px){
|
|
130
141
|
.homeCrowns{grid-template-columns:minmax(0,1fr);}
|
|
142
|
+
.homeMovers .homeWide{grid-column:auto;}
|
|
131
143
|
}
|
|
132
144
|
@media (max-width:480px){
|
|
133
145
|
.commCrown{grid-template-columns:minmax(0,1fr);}
|
|
@@ -226,6 +238,14 @@
|
|
|
226
238
|
<li class="homeCard"><h3 class="commSub">Builder of the year</h3><p class="crownWhen" id="crownYearWhen"></p><div class="homeBody" id="crownYear"></div><p class="homeLast" id="crownYearLast" hidden></p></li>
|
|
227
239
|
</ul>
|
|
228
240
|
<p class="commNote" id="homeRule">Each is the builder who earned the most {{currency}} across every project in that time, among the builders who shipped at least one work in it. A tie goes to whoever shipped more works, then to whoever earned more karma. Builders who hide their numbers or keep their account private are not counted. The week and the month are decided once, when they end; the year shows the leader so far, and is decided on 1 January.</p>
|
|
241
|
+
<!-- THE ROW UNDER THE CROWNS (task 1002983; owner, 2026-10-28): the rising builder and
|
|
242
|
+
the projects on the move, the last 30 days, from GET /community/movers
|
|
243
|
+
(community-movers.js decides both). Its own read: a failed one drops only this row. -->
|
|
244
|
+
<ul class="homeCrowns homeMovers" id="homeMovers" hidden>
|
|
245
|
+
<li class="homeCard"><h3 class="commSub">Rising builder</h3><p class="crownWhen">The last 30 days</p><div class="homeBody" id="moveRising"></div></li>
|
|
246
|
+
<li class="homeCard homeWide"><h3 class="commSub">Projects on the move</h3><p class="crownWhen">The last 30 days</p><ol class="commRows moveRows" id="moveProjects"></ol><p class="commMsg" id="moveNone" hidden>No public project shipped a work in the last 30 days.</p></li>
|
|
247
|
+
</ul>
|
|
248
|
+
<p class="commNote" id="moversRule" hidden>The rising builder is the one who earned the most {{currency}} in the last 30 days, among the builders who had earned under 100 {{currency}} before them and shipped at least one work in them. Projects on the move are the public projects that shipped the most works in the last 30 days, then earned the most {{currency}}. Builders who hide their numbers or keep their account private are not counted in either.</p>
|
|
229
249
|
<h3 class="commSub" id="doorsH">Community pages</h3>
|
|
230
250
|
<ul class="homeDoors" aria-labelledby="doorsH">
|
|
231
251
|
<li><a class="btn ghost" href="/community/leaderboard">Leaderboard</a></li>
|
|
@@ -677,6 +697,44 @@
|
|
|
677
697
|
});
|
|
678
698
|
}
|
|
679
699
|
|
|
700
|
+
// ── the row under the crowns: the rising builder and the projects on the move ──
|
|
701
|
+
// (task 1002983) community-movers.js decides both; a failed read leaves the row down.
|
|
702
|
+
function moveRow(p, i) {
|
|
703
|
+
var li = el('li', 'commRow');
|
|
704
|
+
li.appendChild(el('span', 'commPlace', String(i + 1)));
|
|
705
|
+
var main = el('div', 'mineRow__main');
|
|
706
|
+
var name = String(p.name || 'A project');
|
|
707
|
+
var href = safeHttpUrl(p.origin);
|
|
708
|
+
if (href) {
|
|
709
|
+
var a = el('a', 'mineRow__name', name);
|
|
710
|
+
a.setAttribute('href', href);
|
|
711
|
+
main.appendChild(a);
|
|
712
|
+
} else main.appendChild(el('div', 'mineRow__name', name));
|
|
713
|
+
main.appendChild(el('p', 'mineRow__hint', num(p.credits).toLocaleString() + ' ' + UNIT + ' earned'));
|
|
714
|
+
li.appendChild(main);
|
|
715
|
+
var works = el('div', 'commCredits');
|
|
716
|
+
var n = num(p.tasks_shipped);
|
|
717
|
+
works.appendChild(el('span', 'commNum', String(n)));
|
|
718
|
+
works.appendChild(el('span', 'commUnit', n === 1 ? 'work shipped' : 'works shipped'));
|
|
719
|
+
li.appendChild(works);
|
|
720
|
+
return li;
|
|
721
|
+
}
|
|
722
|
+
function loadMovers() {
|
|
723
|
+
return read('/community/movers').then(function (r) {
|
|
724
|
+
if (!r.ok) return;
|
|
725
|
+
var box = $('moveRising');
|
|
726
|
+
box.textContent = '';
|
|
727
|
+
if (r.data.rising) fillCrown(box, r.data.rising);
|
|
728
|
+
else box.appendChild(el('p', 'commMsg', 'No rising builder in the last 30 days.'));
|
|
729
|
+
var list = $('moveProjects');
|
|
730
|
+
list.textContent = '';
|
|
731
|
+
arr(r.data.projects).forEach(function (p, i) { if (p) list.appendChild(moveRow(p, i)); });
|
|
732
|
+
$('moveNone').hidden = list.children.length > 0;
|
|
733
|
+
$('homeMovers').hidden = false;
|
|
734
|
+
$('moversRule').hidden = false;
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
680
738
|
// ── the search ──────────────────────────────────────────────────────────────
|
|
681
739
|
function say(text, kind) {
|
|
682
740
|
var s = $('findStatus');
|
|
@@ -806,7 +864,7 @@
|
|
|
806
864
|
if (view.title) document.title = document.title + ' · ' + view.title;
|
|
807
865
|
$('signinTop').setAttribute('href', '/api/bongos/auth/web/start?return=' + view.path + '&repos=1');
|
|
808
866
|
if (VIEW === 'leaderboard') { loadBoard(); read('/community/builder-of-the-week').then(crownLine); }
|
|
809
|
-
if (VIEW === 'home') loadHome();
|
|
867
|
+
if (VIEW === 'home') { loadHome(); loadMovers(); }
|
|
810
868
|
else if (VIEW === 'week') loadWeek();
|
|
811
869
|
else if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
|
|
812
870
|
else bar.ready.then(function (me) { if (me) loadOwned(me, $(VIEW === 'find' ? 'findFor' : 'boardFor')); });
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
|
|
9
9
|
"ignoreControls": [".vh"],
|
|
10
10
|
"states": {
|
|
11
|
-
"out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .crownName", "#crownMonth .crownName", "#crownYear .crownName", "#homeRule", "#signinTop"], "hidden": ["#board", "#week", "#find", "#recruit", "#who", "#homeLoading", "#homeFailed", "#crownYearLast", ".homeDoors .knowCell"], "count": [[".homeDoors li:not([hidden])", 4]], "href": [[".homeDoors li:first-child a", "/community/leaderboard"]], "attr": [["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]], "text": [["#crownWeekWhen", "26 Oct – 1 Nov 2026"], ["#crownWeek .crownName", "Ada Okafor"], ["#crownMonthWhen", "October 2026"], ["#crownMonth .crownName", "Priya Nair"], ["#crownMonth .crownNums div:first-child .commNum", "6,120"], ["#crownYearWhen", "2026 so far, since 27 Sep 2026"], ["#crownYear .crownName", "Maximiliana van der Berghe-Lindqvist"]] } },
|
|
11
|
+
"out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .crownName", "#crownMonth .crownName", "#crownYear .crownName", "#homeRule", "#signinTop", "#homeMovers", "#moveRising .crownName", "#moversRule"], "hidden": ["#board", "#week", "#find", "#recruit", "#who", "#homeLoading", "#homeFailed", "#crownYearLast", ".homeDoors .knowCell", "#moveNone"], "count": [[".homeDoors li:not([hidden])", 4], ["#moveProjects .commRow", 5]], "href": [[".homeDoors li:first-child a", "/community/leaderboard"], ["#moveProjects .commRow:first-child a", "https://tidepool.example/"]], "attr": [["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]], "text": [["#crownWeekWhen", "26 Oct – 1 Nov 2026"], ["#crownWeek .crownName", "Ada Okafor"], ["#crownMonthWhen", "October 2026"], ["#crownMonth .crownName", "Priya Nair"], ["#crownMonth .crownNums div:first-child .commNum", "6,120"], ["#crownYearWhen", "2026 so far, since 27 Sep 2026"], ["#crownYear .crownName", "Maximiliana van der Berghe-Lindqvist"], ["#moveRising .crownName", "Kofi Mensah"], ["#moveProjects .commRow:first-child .commNum", "23"], ["#moveProjects .commRow:first-child .mineRow__hint", "1,840 credits earned"]] } },
|
|
12
12
|
"in": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#home", "#homeCrowns", "#who", ".homeDoors .knowCell"], "hidden": ["#board", "#signinTop", "#homeFailed"], "count": [[".homeDoors li:not([hidden])", 5]], "href": [[".homeDoors .knowCell a", "/connections#connSuggest"]] } },
|
|
13
13
|
"out-board": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList", "#boardWindow", "#signinTop", "#projTrig", "#boardCrown"], "hidden": ["#home", "#week", "#find", "#recruit", "#who", "#boardLoading", "#boardFailed", "#boardEmpty"], "href": [["#boardCrown a", "/community/week"]], "count": [["#boardList .commRow", 6]], "value": [["#boardWindow", "all"]], "attr": [["#craftTabs [data-craft=\"\"]", "aria-pressed", "true"], ["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community/leaderboard\"]", "aria-current", "page"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Priya Nair"], ["#boardList .commRow:first-child .commNum", "18,420"], ["#boardList .commRow:first-child .mineRow__hint", "212 works shipped · 64 karma"], ["#boardNote", "Credits earned across every project, since each builder started."], ["#boardCrown", "Builder of the week: Ada Okafor, 1,240 credits, 19–25 Oct"]] } },
|
|
14
14
|
"out-30d": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400], ["select", "#boardWindow", "30d"], ["wait", 300]], "expect": { "count": [["#boardList .commRow", 3]], "value": [["#boardWindow", "30d"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Ada Okafor"], ["#boardNote", "Credits earned in the last 30 days, across every project."]] } },
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "GET /community/movers with nobody to show (task 1002983): no newer builder shipped a work in the last 30 days, and no public project did — what the hub may show while its numbers are quiet.",
|
|
3
|
+
"window": { "start": "2026-09-28T09:30:00.000Z", "end": "2026-10-28T09:30:00.000Z" },
|
|
4
|
+
"rising": null,
|
|
5
|
+
"projects": []
|
|
6
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "GET /community/movers (task 1002983): the rising builder and the projects on the move over the last 30 days, as community-movers.js answers — five projects, one with a long name, and the rising builder a newer builder than the crowns'.",
|
|
3
|
+
"window": { "start": "2026-09-28T09:30:00.000Z", "end": "2026-10-28T09:30:00.000Z" },
|
|
4
|
+
"rising": { "handle": "kofi-mensah", "github_login": "kofimensah", "display_name": "Kofi Mensah", "avatar_url": "/__kit/avatar.svg", "credits": 640, "tasks_shipped": 5 },
|
|
5
|
+
"projects": [
|
|
6
|
+
{ "name": "Tidepool", "origin": "https://tidepool.example", "tasks_shipped": 23, "credits": 1840 },
|
|
7
|
+
{ "name": "Lantern Atlas", "origin": "https://lantern-atlas.example", "tasks_shipped": 17, "credits": 2210 },
|
|
8
|
+
{ "name": "The Long Weekend Collective's Field Notes and Recipes", "origin": "https://field-notes.example", "tasks_shipped": 9, "credits": 610 },
|
|
9
|
+
{ "name": "Orbit Garden", "origin": "https://orbit-garden.example", "tasks_shipped": 4, "credits": 380 },
|
|
10
|
+
{ "name": "Quill", "origin": "https://quill.example", "tasks_shipped": 1, "credits": 45 }
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -749,6 +749,13 @@ function communityCrowns(res, origin) {
|
|
|
749
749
|
year: { ...year, winner: shown(year.winner), last: year.last ? { ...year.last, winner: shown(year.last.winner) } : null },
|
|
750
750
|
});
|
|
751
751
|
}
|
|
752
|
+
// The movers (task 1002983): the fixture is already the route's answer — the 30 days, the
|
|
753
|
+
// rising builder or null, and the projects on the move best first (community-movers.js).
|
|
754
|
+
function communityMovers(res, origin) {
|
|
755
|
+
const f = heldFixture('community-movers');
|
|
756
|
+
if (stubStatus(res, f)) return;
|
|
757
|
+
return json(res, 200, { window: f.window, rising: f.rising ? withAvatar(f.rising, origin) : null, projects: f.projects || [] });
|
|
758
|
+
}
|
|
752
759
|
function scoutingRead(res, origin) {
|
|
753
760
|
const f = heldFixture('scouting');
|
|
754
761
|
if (stubStatus(res, f)) return;
|
|
@@ -788,6 +795,7 @@ const server = http.createServer((req, res) => {
|
|
|
788
795
|
if (rest === '/community/search' && req.method === 'GET') return communitySearch(res, u, origin);
|
|
789
796
|
if (rest === '/community/builder-of-the-week' && req.method === 'GET') return communityWeek(res, origin);
|
|
790
797
|
if (rest === '/community/crowns' && req.method === 'GET') return communityCrowns(res, origin);
|
|
798
|
+
if (rest === '/community/movers' && req.method === 'GET') return communityMovers(res, origin);
|
|
791
799
|
if (rest === '/scouting' && req.method === 'GET') return AUTH ? scoutingRead(res, origin) : json(res, 401, UNAUTH);
|
|
792
800
|
if (rest === '/connections' && req.method === 'GET') return AUTH ? connRead(res, 'list', origin) : json(res, 401, UNAUTH);
|
|
793
801
|
if (rest === '/connections/requests' && req.method === 'GET') return AUTH ? connRead(res, 'requests', origin) : json(res, 401, UNAUTH);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.20",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.23.
|
|
9
|
+
"version": "1.23.20",
|
|
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.23.
|
|
3
|
+
"version": "1.23.20",
|
|
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
|
@@ -10503,5 +10503,11 @@
|
|
|
10503
10503
|
"id": "1004902",
|
|
10504
10504
|
"text": "The community page now opens on a Community home: the builder of the week, of the month and of the year side by side, how they are chosen, and a button to each community page. The leaderboard has its own page now, and Communit"
|
|
10505
10505
|
}
|
|
10506
|
+
],
|
|
10507
|
+
"1.23.20": [
|
|
10508
|
+
{
|
|
10509
|
+
"id": "1002983",
|
|
10510
|
+
"text": "The Community home now shows a rising builder and the projects on the move this month, under the builders of the week, month and year."
|
|
10511
|
+
}
|
|
10506
10512
|
]
|
|
10507
10513
|
}
|
|
@@ -79,6 +79,10 @@ const INTEGRATION = new Set([
|
|
|
79
79
|
// keeps the week's baseline rule, the year counts from a row's first snapshot inside
|
|
80
80
|
// it, the pin under a hide and two racing writers. The DB-free sibling is community_crowns.
|
|
81
81
|
'community_crowns_db',
|
|
82
|
+
// task 1002983: the rising builder and the projects on the move against real SQL — the
|
|
83
|
+
// year's baseline over 30 days, a quiet row's live total, hidden and private left out.
|
|
84
|
+
// The DB-free sibling is community_movers.
|
|
85
|
+
'community_movers_db',
|
|
82
86
|
// bfg, bfg_write, memory_isolation, memory_pull and setup_smoke LEFT this set under task
|
|
83
87
|
// 1004865: none of them ever needed a database. Each failed on a stale test harness (a fake
|
|
84
88
|
// server without res.fail, a stub on the wrong module, an old error shape, a renamed session
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.20'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// tests/community_movers.mjs — the rising builder and the projects on the move behind the
|
|
2
|
+
// row under the crowns on the hub's /community home (task 1002983).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. A fake pool answers each statement by its shape. Which snapshots open and close
|
|
5
|
+
// the 30 days, and the baseline a quiet row brings, need a real Postgres:
|
|
6
|
+
// tests/community_movers_db.mjs.
|
|
7
|
+
//
|
|
8
|
+
// What this pins:
|
|
9
|
+
// 1. the statement: the crowns' period rows by the year's rule over the last 30 days, the
|
|
10
|
+
// numbers predicate, and a quiet row's live total as its baseline.
|
|
11
|
+
// 2. the rising builder: under RISING_BASE_MAX before, at least one work, the crowns' tie
|
|
12
|
+
// order; a builder who may not be shown is not counted.
|
|
13
|
+
// 3. the projects: named only on the map with the Public door, ranked by works then
|
|
14
|
+
// credits, at most PROJECTS_MAX, counting only builders who may be shown.
|
|
15
|
+
// 4. the memo: the 30 days computed once per MOVERS_TTL_MS, who and which projects asked
|
|
16
|
+
// on every read.
|
|
17
|
+
// 5. the route: no-store, the same body with or without credentials, the 500 envelope,
|
|
18
|
+
// and its own rate-limit scope.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/community_movers.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const express = require('express');
|
|
28
|
+
|
|
29
|
+
let passed = 0;
|
|
30
|
+
let failed = 0;
|
|
31
|
+
async function t(name, fn) {
|
|
32
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
33
|
+
catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { rollupVisibleSql } = require('../modules/platform-identity/account-visibility.js');
|
|
37
|
+
const { onMapSql } = require('../modules/platform-identity/projects-feed.js');
|
|
38
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
39
|
+
const NUMBERS = flat(rollupVisibleSql('pa'));
|
|
40
|
+
const ON_MAP = flat(onMapSql('p'));
|
|
41
|
+
|
|
42
|
+
// ---- fake engine ------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const isRows = (sql) => /^WITH act AS/.test(sql) && /LEFT JOIN per_project pp/.test(sql);
|
|
45
|
+
const isWho = (sql) => /FROM platform_identity_accounts pa WHERE pa\.github_id = ANY\(\$1::bigint\[\]\)/.test(sql);
|
|
46
|
+
const isNamed = (sql) => /FROM platform_identity_sso_clients c JOIN platform_identity_projects p/.test(sql);
|
|
47
|
+
|
|
48
|
+
const engine = {
|
|
49
|
+
calls: [],
|
|
50
|
+
rows: [], // the 30 days' statement's rows
|
|
51
|
+
shown: new Set(), // github_ids the numbers predicate admits
|
|
52
|
+
named: {}, // client_id → { name, origin } on the map with the Public door
|
|
53
|
+
throwOn: null,
|
|
54
|
+
query: async (text, params = []) => {
|
|
55
|
+
const sql = flat(String(text));
|
|
56
|
+
engine.calls.push({ sql, params });
|
|
57
|
+
if (engine.throwOn && engine.throwOn(sql)) { engine.throwOn = null; throw new Error('boom'); }
|
|
58
|
+
if (isRows(sql)) return { rows: engine.rows };
|
|
59
|
+
if (isWho(sql)) {
|
|
60
|
+
const rows = params[0].filter((id) => engine.shown.has(id))
|
|
61
|
+
.map((id) => ({ github_id: id, handle: `h${id}`, github_login: `l${id}`, display_name: `D${id}`, avatar_url: null }));
|
|
62
|
+
return { rows };
|
|
63
|
+
}
|
|
64
|
+
if (isNamed(sql)) {
|
|
65
|
+
const rows = params[0].filter((c) => engine.named[c]).map((c) => ({ client_id: c, ...engine.named[c] }));
|
|
66
|
+
return { rows };
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`fake pool: unexpected statement ${sql.slice(0, 80)}`);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
function reset() {
|
|
72
|
+
Object.assign(engine, { calls: [], rows: [], shown: new Set(), named: {}, throwOn: null });
|
|
73
|
+
movers.clearMoversMemo();
|
|
74
|
+
}
|
|
75
|
+
const callsOf = (pred) => engine.calls.filter((c) => pred(c.sql));
|
|
76
|
+
// One (project, builder) row as the statement returns it: deltas null for a quiet row.
|
|
77
|
+
const r = (client, id, credits, ships, base, karma = 0) => ({
|
|
78
|
+
client_id: client, github_id: id, credits: credits == null ? null : String(credits),
|
|
79
|
+
tasks_shipped: ships == null ? null : String(ships), karma: credits == null ? null : String(karma), base_credits: String(base),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pin the module doorway (ADR 0083) BEFORE the modules load.
|
|
83
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
84
|
+
require.cache[moduleApiPath] = {
|
|
85
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
86
|
+
exports: {
|
|
87
|
+
pool: engine,
|
|
88
|
+
corsPublicGet: () => (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); },
|
|
89
|
+
logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const movers = require('../modules/platform-identity/community-movers.js');
|
|
94
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
95
|
+
const buildRoutes = require('../modules/platform-identity/routes/community-leaderboard.js');
|
|
96
|
+
|
|
97
|
+
const NOW = '2026-10-28T09:30:00.000Z';
|
|
98
|
+
const read = (now = NOW) => movers.readMovers({ now: new Date(now), pool: engine });
|
|
99
|
+
|
|
100
|
+
// ---- 1. the statement ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
await t('the 30 days: the crowns’ period rows by the year’s rule, the numbers predicate, a quiet row’s live total as its baseline', async () => {
|
|
103
|
+
reset();
|
|
104
|
+
const out = await read();
|
|
105
|
+
const [s] = callsOf(isRows);
|
|
106
|
+
assert.ok(s.sql.includes(NUMBERS), 'the leaderboard’s numbers predicate');
|
|
107
|
+
assert.match(s.sql, /COALESCE\(e\.credits, 0\)::bigint - COALESCE\(b\.credits, CASE WHEN act\.first_seen_at >= \$1::timestamptz THEN 0 ELSE f\.credits END, 0\) AS credits/, 'the year’s baseline: never a lifetime');
|
|
108
|
+
assert.match(s.sql, /WHERE b\.credits IS NOT NULL OR act\.first_seen_at >= \$1::timestamptz OR f\.credits IS NOT NULL \)/);
|
|
109
|
+
assert.match(s.sql, /COALESCE\(pp\.base_credits, a\.credits::bigint\) AS base_credits/, 'a row with no history in the 30 days brings its live total');
|
|
110
|
+
assert.match(s.sql, /LEFT JOIN per_project pp ON pp\.client_id = act\.client_id AND pp\.github_id = act\.github_id$/);
|
|
111
|
+
assert.deepEqual(s.params.map((d) => d.toISOString()), ['2026-09-28T09:30:00.000Z', NOW], 'rolling: the 30 days up to now');
|
|
112
|
+
assert.deepEqual(out, { window: { start: '2026-09-28T09:30:00.000Z', end: NOW }, rising: null, projects: [] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---- 2. the rising builder ----------------------------------------------------
|
|
116
|
+
|
|
117
|
+
await t('the rising builder: most credits among those under the base cap with a shipped work', async () => {
|
|
118
|
+
reset();
|
|
119
|
+
engine.rows = [
|
|
120
|
+
r('p1', '1', 900, 4, 500), // a veteran: over the cap
|
|
121
|
+
r('p1', '2', 300, 2, movers.RISING_BASE_MAX - 1),
|
|
122
|
+
r('p1', '3', 400, 0, 0), // no work shipped
|
|
123
|
+
r('p1', '4', 250, 1, 0),
|
|
124
|
+
];
|
|
125
|
+
engine.shown = new Set(['1', '2', '3', '4']);
|
|
126
|
+
const { rising } = await read();
|
|
127
|
+
assert.deepEqual(rising, { handle: 'h2', github_login: 'l2', display_name: 'D2', avatar_url: null, credits: 300, tasks_shipped: 2 });
|
|
128
|
+
assert.doesNotMatch(JSON.stringify(rising), /github_id|karma/, 'the crown’s fields and no more');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await t('the base is summed across every project, a quiet one included, and exactly the cap is over it', async () => {
|
|
132
|
+
reset();
|
|
133
|
+
engine.rows = [
|
|
134
|
+
r('p1', '2', 300, 2, 40),
|
|
135
|
+
r('p2', '2', null, null, 60), // quiet: its live total is its baseline
|
|
136
|
+
r('p1', '4', 250, 1, 0),
|
|
137
|
+
];
|
|
138
|
+
engine.shown = new Set(['2', '4']);
|
|
139
|
+
assert.equal((await read()).rising.handle, 'h4', '40 + 60 is not under 100');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t('ties go to works shipped, then karma, then handle', async () => {
|
|
143
|
+
reset();
|
|
144
|
+
engine.rows = [r('p1', '5', 200, 1, 0, 9), r('p1', '6', 200, 2, 0, 0), r('p1', '7', 200, 2, 0, 1)];
|
|
145
|
+
engine.shown = new Set(['5', '6', '7']);
|
|
146
|
+
assert.equal((await read()).rising.handle, 'h7');
|
|
147
|
+
reset();
|
|
148
|
+
engine.rows = [r('p1', '8', 200, 2, 0, 1), r('p1', '7', 200, 2, 0, 1)];
|
|
149
|
+
engine.shown = new Set(['7', '8']);
|
|
150
|
+
assert.equal((await read()).rising.handle, 'h7', 'handle, by code unit');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await t('a builder who may not be shown is not the rising builder, and nobody takes it in their place unless they qualify', async () => {
|
|
154
|
+
reset();
|
|
155
|
+
engine.rows = [r('p1', '2', 300, 2, 0), r('p1', '4', 250, 1, 0)];
|
|
156
|
+
engine.shown = new Set(['4']);
|
|
157
|
+
assert.equal((await read()).rising.handle, 'h4');
|
|
158
|
+
const [who] = callsOf(isWho);
|
|
159
|
+
assert.ok(who.sql.includes(NUMBERS), 'who is shown: the numbers predicate');
|
|
160
|
+
assert.deepEqual(who.params[0].sort(), ['2', '4']);
|
|
161
|
+
reset();
|
|
162
|
+
engine.rows = [r('p1', '2', 300, 2, 0)];
|
|
163
|
+
assert.equal((await read()).rising, null);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ---- 3. the projects on the move ------------------------------------------------
|
|
167
|
+
|
|
168
|
+
await t('the projects: named only on the map with the Public door, by works then credits, at most PROJECTS_MAX', async () => {
|
|
169
|
+
reset();
|
|
170
|
+
engine.shown = new Set(['1', '2', '3']);
|
|
171
|
+
engine.rows = [
|
|
172
|
+
r('a', '1', 100, 3, 0), r('a', '2', 50, 1, 0), // 4 works, 150
|
|
173
|
+
r('b', '1', 900, 4, 0), // 4 works, 900
|
|
174
|
+
r('c', '3', 10, 1, 0),
|
|
175
|
+
r('d', '3', 10, 1, 0),
|
|
176
|
+
r('e', '3', 20, 1, 0),
|
|
177
|
+
r('f', '3', 5, 1, 0),
|
|
178
|
+
r('g', '3', 0, 0, 0), // nothing shipped
|
|
179
|
+
r('hidden', '3', 999, 9, 0), // private or stealth: not named
|
|
180
|
+
];
|
|
181
|
+
for (const c of ['a', 'b', 'c', 'd', 'e', 'f', 'g']) engine.named[c] = { name: c.toUpperCase(), origin: `https://${c}.example` };
|
|
182
|
+
const { projects } = await read();
|
|
183
|
+
assert.deepEqual(projects.map((p) => p.name), ['B', 'A', 'E', 'C', 'D'], 'works, then credits, then name');
|
|
184
|
+
assert.equal(projects.length, movers.PROJECTS_MAX);
|
|
185
|
+
assert.deepEqual(projects[1], { name: 'A', origin: 'https://a.example', tasks_shipped: 4, credits: 150 });
|
|
186
|
+
const [named] = callsOf(isNamed);
|
|
187
|
+
assert.ok(named.sql.includes(`${ON_MAP} AND p.visibility = 'public'`), 'a profile’s naming rule (task 1003558)');
|
|
188
|
+
assert.doesNotMatch(JSON.stringify(projects), /client_id|github_id|"hidden"/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await t('a project counts only the builders who may be shown', async () => {
|
|
192
|
+
reset();
|
|
193
|
+
engine.shown = new Set(['1']);
|
|
194
|
+
engine.rows = [r('a', '1', 100, 1, 0), r('a', '2', 500, 5, 0), r('b', '2', 900, 9, 0)];
|
|
195
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' }, b: { name: 'B', origin: 'https://b.example' } };
|
|
196
|
+
const { projects } = await read();
|
|
197
|
+
assert.deepEqual(projects, [{ name: 'A', origin: 'https://a.example', tasks_shipped: 1, credits: 100 }]);
|
|
198
|
+
assert.deepEqual(callsOf(isNamed)[0].params[0], ['a'], 'a project only a hidden builder moved is not even asked about');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ---- 4. the memo ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
await t('the 30 days are computed once per MOVERS_TTL_MS; who and which projects are asked every read', async () => {
|
|
204
|
+
reset();
|
|
205
|
+
engine.rows = [r('a', '2', 300, 2, 0)];
|
|
206
|
+
engine.shown = new Set(['2']);
|
|
207
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' } };
|
|
208
|
+
const t0 = Date.parse(NOW);
|
|
209
|
+
assert.equal((await read(t0)).rising.handle, 'h2');
|
|
210
|
+
engine.shown = new Set(); // the builder hides
|
|
211
|
+
delete engine.named.a; // and the project closes its door
|
|
212
|
+
const hid = await read(t0 + movers.MOVERS_TTL_MS - 1);
|
|
213
|
+
assert.equal(hid.rising, null, 'a hide beats the memo');
|
|
214
|
+
assert.deepEqual(hid.projects, [], 'so does a closed door');
|
|
215
|
+
assert.equal(hid.window.end, NOW, 'inside the memo: the window it was computed for');
|
|
216
|
+
assert.equal(callsOf(isRows).length, 1);
|
|
217
|
+
assert.equal(callsOf(isWho).length, 2);
|
|
218
|
+
await read(t0 + movers.MOVERS_TTL_MS);
|
|
219
|
+
assert.equal(callsOf(isRows).length, 2, 'stale: computed again');
|
|
220
|
+
await read(t0 - 1000);
|
|
221
|
+
assert.equal(callsOf(isRows).length, 3, 'a clock that runs backwards is never served from the memo');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---- 5. the route -----------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
function serve() {
|
|
227
|
+
const app = express();
|
|
228
|
+
app.use(attachFail);
|
|
229
|
+
app.use('/api/bongos', buildRoutes());
|
|
230
|
+
const server = app.listen(0, '127.0.0.1');
|
|
231
|
+
return new Promise((resolve) => server.once('listening', () => resolve(server)));
|
|
232
|
+
}
|
|
233
|
+
function get(server, path_, headers = {}) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
http.get({ host: '127.0.0.1', port: server.address().port, path: path_, headers }, (res) => {
|
|
236
|
+
let body = '';
|
|
237
|
+
res.on('data', (c) => (body += c));
|
|
238
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, json: body ? JSON.parse(body) : null }));
|
|
239
|
+
}).on('error', reject);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const server = await serve();
|
|
244
|
+
const URL_ = '/api/bongos/community/movers';
|
|
245
|
+
|
|
246
|
+
await t('GET /community/movers: the window, the rising builder and the projects; no-store; CORS-open; the same for every viewer', async () => {
|
|
247
|
+
reset();
|
|
248
|
+
engine.rows = [r('a', '2', 300, 2, 0)];
|
|
249
|
+
engine.shown = new Set(['2']);
|
|
250
|
+
engine.named = { a: { name: 'A', origin: 'https://a.example' } };
|
|
251
|
+
const anon = await get(server, URL_);
|
|
252
|
+
assert.equal(anon.status, 200);
|
|
253
|
+
assert.equal(anon.headers['cache-control'], 'no-store');
|
|
254
|
+
assert.equal(anon.headers['access-control-allow-origin'], '*');
|
|
255
|
+
assert.deepEqual(Object.keys(anon.json), ['window', 'rising', 'projects']);
|
|
256
|
+
assert.equal(anon.json.rising.handle, 'h2');
|
|
257
|
+
assert.equal(anon.json.projects[0].name, 'A');
|
|
258
|
+
assert.doesNotMatch(anon.body, /github_id|client_id/);
|
|
259
|
+
const signedIn = await get(server, URL_, { Cookie: 'bongos_session=x', Authorization: 'Bearer y' });
|
|
260
|
+
assert.equal(signedIn.body, anon.body, 'the route reads no session');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await t('a failure is the 500 envelope, never a stack', async () => {
|
|
264
|
+
reset();
|
|
265
|
+
engine.throwOn = isRows;
|
|
266
|
+
const res = await get(server, URL_);
|
|
267
|
+
assert.equal(res.status, 500);
|
|
268
|
+
assert.equal(res.json.error.code, 'community_movers_failed');
|
|
269
|
+
assert.doesNotMatch(res.body, /boom/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await t('its own rate-limit budget, not the board’s, the week’s or the crowns’', () => {
|
|
273
|
+
const { _internals } = buildRoutes;
|
|
274
|
+
assert.equal(_internals.MOVERS_READ_RATE_SCOPE, 'community-movers-read');
|
|
275
|
+
assert.equal(new Set([_internals.MOVERS_READ_RATE_SCOPE, _internals.CROWNS_READ_RATE_SCOPE, _internals.READ_RATE_SCOPE, _internals.WEEK_READ_RATE_SCOPE]).size, 4);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
server.close();
|
|
279
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
280
|
+
if (failed) process.exit(1);
|