@bongos/core 1.19.473 → 1.19.475

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.
@@ -0,0 +1,227 @@
1
+ // roster.js — the Builders page: who builds on THIS project, ranked.
2
+ //
3
+ // Task 1003441 (goal 1000075, criterion community-is-one-builders-page). This
4
+ // was the `#leaderboard` HASH VIEW on index.html, which is why the hall's
5
+ // Community nav group read as one real page plus a link into the home page. It
6
+ // is a page now, and `/#leaderboard` redirects here (shell.js VIEW_REDIRECTS).
7
+ //
8
+ // PROJECT-OWN, not cross-project. /people is the cross-project directory behind
9
+ // `project.curate`; this is the project's own ledger and is deliberately
10
+ // ungated, because its one data source — GET /leaderboard
11
+ // (modules/economy/routes/leaderboard.js) — is deliberately unauthenticated and
12
+ // mirrors the public status surface. No visibility predicate is applied and that
13
+ // is CORRECT: privacy suppresses cross-project surfaces only, so a builder
14
+ // marked `private` still appears on the roster of a project they build on
15
+ // (owner, 2026-08-30; ADR 0225, task 1003443).
16
+ //
17
+ // The laurels (ACH_EMOJI + achRowHtml) and the sort lenses MOVED here from
18
+ // hall-render.js rather than being copied — the leaderboard row was their only
19
+ // caller, so the home page is left carrying no dead renderer.
20
+ (function () {
21
+ 'use strict';
22
+ var kit = window.OTBKit;
23
+ var OTB = window.OTB;
24
+ var esc = kit.esc;
25
+ var body = document.getElementById('roster-body');
26
+ var sub = document.getElementById('roster-sub');
27
+ var countPill = document.getElementById('roster-count');
28
+
29
+ var fmtInt = function (n) { return OTB && OTB.fmtInt ? OTB.fmtInt(n) : String(n == null ? 0 : n); };
30
+ var rankLabel = function (r) { return OTB && OTB.rankLabel ? OTB.rankLabel(r) : r; };
31
+ var currencyLabel = function () {
32
+ var b = window.OTBBranding;
33
+ return (b && b.currencyLabel && b.currencyLabel({ caps: true })) || 'Credits';
34
+ };
35
+
36
+ // Local, the way builders.js and goals-render.js each keep their own — a
37
+ // four-entry ladder constant, not a shared module's worth of behaviour.
38
+ var RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
39
+ function rankWeight(r) { return RANK_ORDER[r] != null ? RANK_ORDER[r] : -1; }
40
+ function rankBadgeHtml(rank) {
41
+ var variant = RANK_ORDER[rank] ? 'rank-badge--' + rank : 'rank-badge--unknown';
42
+ var aria = rank ? 'Rank: ' + rankLabel(rank) : 'Rank not yet recorded';
43
+ return '<span class="rank-badge ' + variant + '" aria-label="' + esc(aria) + '">' + esc(rankLabel(rank)) + '</span>';
44
+ }
45
+
46
+ var ACH_EMOJI = {
47
+ 'first-ship': '\u{1F6A2}',
48
+ 'first-claim': '\u{1FA99}',
49
+ 'first-parallel-claim': '⚓',
50
+ 'streak-3': '\u{1F525}',
51
+ 'streak-7': '\u{1F30B}',
52
+ 'art-pipeline-shipper': '\u{1F3A8}',
53
+ 'gds-shipper': '⚙️',
54
+ 'breaking-the-100c-bar': '\u{1F4AF}',
55
+ };
56
+ function achRowHtml(ids) {
57
+ if (!Array.isArray(ids) || !ids.length) return '';
58
+ var inner = ids.map(function (id) {
59
+ var emoji = ACH_EMOJI[id];
60
+ // Title is the achievement id (kebab) — terse enough that hover discloses
61
+ // the laurel without overstating the prose.
62
+ return emoji ? '<span class="lb-ach" title="' + esc(id) + '" aria-label="' + esc(id) + '">' + emoji + '</span>' : '';
63
+ }).join('');
64
+ return inner ? '<span class="lb-ach-row" aria-label="achievements">' + inner + '</span>' : '';
65
+ }
66
+
67
+ function builderName(b) { return b.display_name || b.github_login || ('#' + b.id); }
68
+ function initialOf(b) {
69
+ var n = String(builderName(b)).replace(/^[@#]/, '');
70
+ return (n.charAt(0) || '?').toUpperCase();
71
+ }
72
+ // Defence in depth on the avatar src. Escaping alone cannot stop a non-http
73
+ // scheme reaching an attribute the browser resolves; <img> would not execute
74
+ // javascript: in any current browser, but the allowlist costs one test and
75
+ // removes the question. Falls back to the initial rather than rendering a
76
+ // src the page did not vouch for.
77
+ function httpUrl(u) {
78
+ return /^https?:\/\//i.test(String(u || '')) ? String(u) : null;
79
+ }
80
+ // The identity cell, shared with /people through people.css. The name opens
81
+ // the builder's profile PAGE here: this is a project surface, so a local
82
+ // profile id exists — unlike the cross-project directory, which can only
83
+ // offer GitHub. Guarded on b.id so a row without one degrades to plain text
84
+ // rather than a broken /profile?id=undefined link (task 1467).
85
+ function whoHtml(b) {
86
+ var name = esc(builderName(b));
87
+ var src = httpUrl(b.avatar_url);
88
+ var avatar = src
89
+ ? '<img class="dir-avatar" src="' + esc(src) + '" alt="" loading="lazy">'
90
+ : '<span class="dir-avatar dir-avatar--initial" aria-hidden="true">' + esc(initialOf(b)) + '</span>';
91
+ var nameHtml = b.id != null
92
+ ? '<a class="lb-name dir-name" href="/profile?id=' + encodeURIComponent(b.id) + '">' + name + '</a>'
93
+ : '<span class="dir-name">' + name + '</span>';
94
+ var login = b.github_login ? '<span class="dir-login">@' + esc(b.github_login) + '</span>' : '';
95
+ return '<span class="dir-who">' + avatar + '<span class="dir-who__t">' + nameHtml + login + '</span></span>';
96
+ }
97
+
98
+ var all = [];
99
+ var query = '';
100
+ var sortBy = '';
101
+
102
+ // PLACE IS CANONICAL, not the row's position in the current sort.
103
+ // hall-render's leaderboardPlace() already defines a builder's place as
104
+ // credits-descending "independent of whatever column the table is currently
105
+ // sorted by", and the Standing card still says "#N on the leaderboard" from
106
+ // that definition — so a Place column that renumbered itself under the karma
107
+ // lens disagreed with the card about the same builder. Stamped once, at load.
108
+ function stampPlaces(rows) {
109
+ rows.slice()
110
+ .sort(function (a, b) { return (Number(b.total_credits) || 0) - (Number(a.total_credits) || 0); })
111
+ .forEach(function (b, i) { b.__place = i + 1; });
112
+ // The efficiency title is stamped here too, so the division and the string
113
+ // work happen ONCE per load rather than per visible row per render (every
114
+ // sort, search keystroke and page turn re-renders).
115
+ rows.forEach(function (b) { b.__eff = efficiencyTitle(b); });
116
+ return rows;
117
+ }
118
+
119
+ function matches(b) {
120
+ if (!query) return true;
121
+ var hay = ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase();
122
+ return hay.indexOf(query) !== -1;
123
+ }
124
+ // The server returns credits-DESC, which is also the default lens; the others
125
+ // re-order the loaded set client-side, ties keeping the server's order so
126
+ // equal-value rows do not jitter between renders.
127
+ var SORTS = {
128
+ '': null,
129
+ rank: function (b) { return -rankWeight(b.rank); },
130
+ karma: function (b) { return -(Number(b.karma) || 0); },
131
+ tokens: function (b) { return -(Number(b.tokens_used) || 0); },
132
+ };
133
+ function ordered(rows) {
134
+ var key = SORTS[sortBy];
135
+ if (!key) return rows;
136
+ return rows.map(function (b, i) { return { b: b, i: i }; })
137
+ .sort(function (x, y) { return (key(x.b) - key(y.b)) || (x.i - y.i); })
138
+ .map(function (x) { return x.b; });
139
+ }
140
+
141
+ var pager = kit.makePager('roster', 'hall-roster-per-page', { defaultPerPage: 25 });
142
+ var ledger = null;
143
+
144
+ function efficiencyTitle(b) {
145
+ var credits = Number(b.total_credits) || 0;
146
+ var one = currencyLabel().toLowerCase().replace(/s$/, '');
147
+ if (credits <= 0) return 'no ' + currencyLabel().toLowerCase() + ' earned yet';
148
+ return fmtInt(Math.round((Number(b.tokens_used) || 0) / credits)) + ' tokens per ' + one;
149
+ }
150
+
151
+ function rowHtml(b) {
152
+ return '<tr>'
153
+ + '<td class="ledger__num">' + esc(String(b.__place || '—')) + '</td>'
154
+ + '<td>' + whoHtml(b) + achRowHtml(b.achievements) + '</td>'
155
+ + '<td class="ledger__rank">' + rankBadgeHtml(b.rank) + '</td>'
156
+ + '<td class="ledger__num">' + esc(fmtInt(b.total_credits)) + '</td>'
157
+ + '<td class="ledger__num">' + esc(fmtInt(b.karma)) + '</td>'
158
+ + '<td class="ledger__num" title="' + esc(b.__eff || '') + '">' + esc(fmtInt(b.tokens_used)) + '</td>'
159
+ + '</tr>';
160
+ }
161
+
162
+ function rerender() {
163
+ if (ledger) ledger.render(ordered(all.filter(matches)));
164
+ }
165
+
166
+ function notice(msg) {
167
+ body.innerHTML = '';
168
+ body.className = 'note';
169
+ body.textContent = msg;
170
+ }
171
+
172
+ function mountRoster(rows) {
173
+ all = stampPlaces(Array.isArray(rows) ? rows : []);
174
+ if (!all.length) {
175
+ notice('Nobody has earned anything on this project yet. A builder joins the roster the '
176
+ + 'first time they ship a claimed task.');
177
+ return;
178
+ }
179
+ if (countPill) { countPill.textContent = String(all.length); countPill.hidden = false; }
180
+ if (sub) {
181
+ sub.textContent = fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's')
182
+ + ' on this project, ranked by ' + currencyLabel().toLowerCase() + ' earned. This '
183
+ + 'project’s own ledger — everyone who builds here is listed.';
184
+ }
185
+ kit.makeFilterBar({
186
+ mount: document.getElementById('roster-filters'),
187
+ search: { id: 'roster-q', label: 'Search', placeholder: 'name or login' },
188
+ primary: [
189
+ { id: 'roster-sort',
190
+ label: 'Sort',
191
+ options: [
192
+ { value: '', label: 'place' },
193
+ { value: 'rank', label: 'rank' },
194
+ { value: 'karma', label: 'most karma' },
195
+ { value: 'tokens', label: 'most tokens' },
196
+ ] },
197
+ ],
198
+ onChange: function (vals) {
199
+ query = String(vals['roster-q'] || '').trim().toLowerCase();
200
+ sortBy = String(vals['roster-sort'] || '');
201
+ pager.page = 1;
202
+ rerender();
203
+ },
204
+ });
205
+ body.textContent = '';
206
+ body.className = '';
207
+ ledger = kit.makeLedger({
208
+ mount: body,
209
+ columns: ['Place', 'Builder', 'Rank',
210
+ { label: currencyLabel(), class: 'ledger__num' },
211
+ { label: 'Karma', class: 'ledger__num' },
212
+ { label: 'Tokens', class: 'ledger__num' }],
213
+ rowHtml: rowHtml,
214
+ emptyMsg: 'No builder on the roster matches that search.',
215
+ pager: pager,
216
+ });
217
+ rerender();
218
+ }
219
+
220
+ fetch('/api/gds/leaderboard', { headers: { Accept: 'application/json' } })
221
+ .then(function (r) {
222
+ if (!r.ok) throw new Error('http ' + r.status);
223
+ return r.json();
224
+ })
225
+ .then(function (data) { mountRoster(data && data.leaderboard); })
226
+ .catch(function () { notice('Couldn’t load the roster right now.'); });
227
+ }());
@@ -0,0 +1,100 @@
1
+ {
2
+ "_": "Builders — this project's OWN roster, ranked by credits earned (task 1003441, goal 1000075). Was the #leaderboard hash view on index.html; /#leaderboard now redirects here. Deliberately UNGATED, because its one data source (GET /leaderboard) is deliberately unauthenticated and mirrors the public status surface — so unlike people.states.json the `out` state below renders the real page rather than a seal. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/leaderboard.json carries thirteen builders with avatars, ranks, karma and tokens).",
3
+ "page": "/builders/roster",
4
+ "surface": "hall-ui",
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
8
+ "modeQuery": false,
9
+ "_feeds": "The kit's own stub carries no feed for this page, so against it the page renders its EMPTY and SKELETON states — which is the point: those are states the floors must hold in. Against the hall-preview harness the same states render populated. Every declared 404 below is a fixture the harness does not carry, named rather than hidden.",
10
+ "ignoreRequests": [
11
+ "/api/(gds|bongos)/me$",
12
+ "/api/(gds|bongos)/me/",
13
+ "/api/(gds|bongos)/goals",
14
+ "/api/(gds|bongos)/inbox/",
15
+ "/api/(gds|bongos)/versions",
16
+ "/api/(gds|bongos)/builders",
17
+ "/api/(gds|bongos)/leaderboard",
18
+ "/api/(gds|bongos)/public/"
19
+ ],
20
+ "ignoreConsole": [
21
+ "/api/(gds|bongos)/",
22
+ "\\b401\\b",
23
+ "\\b403\\b",
24
+ "\\b404\\b",
25
+ "\\b501\\b",
26
+ "Unauthorized",
27
+ "Forbidden",
28
+ "Not Found",
29
+ "Not Implemented",
30
+ "load failed"
31
+ ],
32
+ "states": {
33
+ "roster": {
34
+ "auth": true,
35
+ "actions": [
36
+ [
37
+ "wait",
38
+ 700
39
+ ]
40
+ ],
41
+ "expect": {
42
+ "visible": [
43
+ "#roster-scroll"
44
+ ]
45
+ }
46
+ },
47
+ "search": {
48
+ "auth": true,
49
+ "actions": [
50
+ [
51
+ "wait",
52
+ 700
53
+ ],
54
+ [
55
+ "fill",
56
+ "#roster-q",
57
+ "prev"
58
+ ],
59
+ [
60
+ "wait",
61
+ 200
62
+ ]
63
+ ],
64
+ "expect": {
65
+ "visible": [
66
+ "#roster-scroll"
67
+ ],
68
+ "value": [
69
+ [
70
+ "#roster-q",
71
+ "prev"
72
+ ]
73
+ ]
74
+ }
75
+ },
76
+ "out": {
77
+ "auth": false,
78
+ "actions": [
79
+ [
80
+ "wait",
81
+ 700
82
+ ]
83
+ ],
84
+ "expect": {
85
+ "visible": [
86
+ "#roster-scroll"
87
+ ]
88
+ }
89
+ }
90
+ },
91
+ "reducedMotion": {
92
+ "auth": true,
93
+ "actions": [
94
+ [
95
+ "wait",
96
+ 600
97
+ ]
98
+ ]
99
+ }
100
+ }
@@ -13,8 +13,9 @@
13
13
  // (#app-sidebar / #app-topbar / #app-foot) every hall page carries;
14
14
  // - rank-gate nav items COSMETICALLY (server-side page gates remain the real
15
15
  // access control — serve-internal.js requireGovernmentPage etc.);
16
- // - on the index (body[data-page="home"]) route the three views (#home,
17
- // #standing, #leaderboard) plus the #achievements alias into Standing. The
16
+ // - on the index (body[data-page="home"]) route the two views (#home,
17
+ // #standing) plus the #achievements alias into Standing, and REDIRECT the
18
+ // retired #leaderboard hash to the /roster page (task 1003441). The
18
19
  // `#/task|idea|blocker/N` deep-link hashes belong to builders.js and are
19
20
  // deliberately ignored here;
20
21
  // - expose window.OTBShell.applyAccess(...) so builders.js (index) or the
@@ -30,7 +31,10 @@
30
31
  var brand = window.__BRANDING__ || {};
31
32
  var PAGE = (document.body && document.body.dataset.page) || '';
32
33
  var IS_INDEX = PAGE === 'home';
33
- var VIEWS = ['home', 'standing', 'leaderboard'];
34
+ // task 1003441: 'leaderboard' left this list — the Roll is the /roster PAGE
35
+ // now. Its hash is not an alias (there is no host view to land on) but a
36
+ // REDIRECT, declared in VIEW_REDIRECTS below.
37
+ var VIEWS = ['home', 'standing'];
34
38
  // task 2174: #achievements retired as a view — the achievements grid folded
35
39
  // into Standing's Achievements rail section; task 2175 then retired its nav
36
40
  // entry. Old links (bookmarks, the stat tiles) keep working: the hash resolves
@@ -47,6 +51,16 @@
47
51
  'standing-progress': { view: 'standing', anchor: 'standing-progress' },
48
52
  'standing-achievements': { view: 'standing', anchor: 'standing-achievements' },
49
53
  };
54
+ // A retired view whose content moved to its OWN PAGE, so there is no host
55
+ // view to alias onto. Every existing #leaderboard link — bookmarks, the
56
+ // Standing card's stat tiles, profile.js's prose link — lands on the roster
57
+ // instead of silently falling through to Home (task 1003441).
58
+ // Object.create(null): the key is an attacker-controlled hash string, so a
59
+ // plain object would answer for "constructor"/"toString" as well as the real
60
+ // entries. Harmless with one literal key, wrong to leave as the pattern.
61
+ var VIEW_REDIRECTS = Object.assign(Object.create(null), {
62
+ leaderboard: '/roster',
63
+ });
50
64
  // builders.js owns these hashes (deep-link overlay) — never route them.
51
65
  var DEEPLINK_RE = /^#\/(task|idea|blocker)\/(\d+)$/;
52
66
 
@@ -157,7 +171,11 @@
157
171
  // Community because the group is picked by WHOSE data it is, and this is
158
172
  // other builders reaching you — not your own numbers (that is You).
159
173
  { id: 'collab', label: 'Collab', icon: 'leaderboard', href: '/collab', gate: 'member' },
160
- { id: 'leaderboard', label: 'Leaderboard', icon: 'leaderboard', href: '/#leaderboard', view: 'leaderboard', gate: 'member' },
174
+ // task 1003441: was the #leaderboard hash view on the home page, which is
175
+ // why this group read as one real page plus a link into index.html. Now a
176
+ // page, so no `view` key. Labelled Builders — 'roster' is the word
177
+ // modules/hall-ui/CLAUDE.md uses, but Builders is what a reader scans for.
178
+ { id: 'roster', label: 'Builders', icon: 'leaderboard', href: '/roster', gate: 'member' },
161
179
  // task 1002577: the cross-project builder directory (/scouting graduated
162
180
  // into the hall). Gated to MATCH its data — /api/gds/scouting checks
163
181
  // `project.curate`, whose catalog floor is METIC. This read 'archon' until
@@ -566,6 +584,12 @@
566
584
  }
567
585
 
568
586
  function route(opts) {
587
+ // The redirect runs BEFORE the view lookup: #leaderboard is no longer a
588
+ // view, so viewFromHash would return nothing and route() would leave the
589
+ // page on Home — a dead link that looks like it worked. replace() rather
590
+ // than assign() so Back does not bounce the reader between the two.
591
+ var redirect = IS_INDEX && VIEW_REDIRECTS[String(window.location.hash || "").replace(/^#/, "")];
592
+ if (redirect) { window.location.replace(redirect); return; }
569
593
  var target = viewFromHash(window.location.hash);
570
594
  if (target) {
571
595
  // An aliased hash owns its scroll position, so suppress setView's
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.473",
3
+ "version": "1.19.475",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.473",
9
+ "version": "1.19.475",
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.473",
3
+ "version": "1.19.475",
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
@@ -34,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
34
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
35
  // the entry to that file. Look for a version's history there, not here.
36
36
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.473'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
+ const CORE_VERSION = '1.19.475'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
38
 
39
39
  // A namespaced logger so a module's log lines are attributable + consistent.
40
40
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -42,11 +42,17 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
42
42
  return el;
43
43
  };
44
44
 
45
+ // replace() is recorded, not ignored: task 1003441 retired the #leaderboard
46
+ // VIEW and route() answers that hash with location.replace('/roster'). A stub
47
+ // without it throws the moment a boot carries that hash, which is precisely
48
+ // why the redirect shipped untested the first time.
49
+ const replaced = [];
45
50
  const location = {
46
51
  hostname: 'builders.cloudbongos.com',
47
52
  origin: 'https://builders.cloudbongos.com',
48
53
  pathname, hash,
49
54
  assign() {},
55
+ replace(target) { replaced.push(String(target)); },
50
56
  };
51
57
 
52
58
  const documentObj = {
@@ -90,6 +96,7 @@ function boot({ page = 'settings', hash = '', pathname = '/settings' } = {}) {
90
96
  sidebarHtml: documentObj.getElementById('app-sidebar').innerHTML,
91
97
  viewHidden: (v) => documentObj.getElementById('view-' + v).hidden,
92
98
  shell: windowObj.OTBShell,
99
+ replaced,
93
100
  };
94
101
  }
95
102
 
@@ -155,7 +162,11 @@ test('sidebar groups read by audience, in order', () => {
155
162
  // First within the group because it is the one page here you ACT on; the
156
163
  // leaderboard and the directory are browsing surfaces. Updating this oracle is
157
164
  // the reviewed decision the pin exists to force.
158
- assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'leaderboard', 'people'], 'Community = everyone else');
165
+ // task 1003441: `leaderboard` → `roster`. The id changed because the THING
166
+ // changed: it was a hash view on index.html (href /#leaderboard, view:
167
+ // 'leaderboard') and is now a real page at /roster labelled Builders. That is
168
+ // what made this group read as one real page plus a link into the home page.
169
+ assert.deepEqual(nav[2].items.map((i) => i.id), ['collab', 'roster', 'people'], 'Community = everyone else');
159
170
  assert.deepEqual(nav[3].items.map((i) => i.id),
160
171
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
161
172
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
@@ -194,6 +205,30 @@ test('group-gate check actually CATCHES a head gated wider than its items', () =
194
205
  assert.equal(headOutOfRange({ label: 'X', headGate: 'archon', items: [{ gate: 'archon' }] }), false);
195
206
  });
196
207
 
208
+ test('#leaderboard REDIRECTS to /roster from the index, and only from the index', () => {
209
+ // task 1003441: the Roll became a page, so its hash has no host view to
210
+ // alias onto. Without the redirect viewFromHash returns nothing and route()
211
+ // leaves the reader on Home — a dead link that looks like it worked, which
212
+ // is the failure this pins. The nav item points at the page, not the hash.
213
+ const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
214
+ assert.ok(!ids.includes('leaderboard'), 'the hash-view nav entry retired with the view');
215
+ assert.ok(ids.includes('roster'), 'the page took its place');
216
+
217
+ const onIndex = boot({ page: 'home', hash: '#leaderboard', pathname: '/' });
218
+ assert.deepEqual(onIndex.replaced, ['/roster'],
219
+ 'the index answers the retired hash with a replace() to the page');
220
+
221
+ // The guard matters: the hash is not this shell’s to interpret on a page
222
+ // that has no views, and hijacking it there would break an in-page anchor
223
+ // that happens to be spelled the same.
224
+ const offIndex = boot({ page: 'settings', hash: '#leaderboard', pathname: '/settings' });
225
+ assert.deepEqual(offIndex.replaced, [], 'a non-index page redirects nothing');
226
+
227
+ // And a hash the map does not name is still left alone entirely.
228
+ const unknown = boot({ page: 'home', hash: '#nope', pathname: '/' });
229
+ assert.deepEqual(unknown.replaced, [], 'an unknown hash is not a redirect');
230
+ });
231
+
197
232
  test('Achievements has no nav entry, but #achievements still routes to Standing', () => {
198
233
  const ids = parseNav(boot().sidebarHtml).flatMap((g) => g.items.map((i) => i.id));
199
234
  assert.ok(!ids.includes('achievements'), 'the nav entry retired with task 2175');
@@ -79,6 +79,14 @@ const ORACLE = {
79
79
 
80
80
  // Deliberately UNGATED — the landing card, the rank ladder, the reward
81
81
  // schedule and a builder's public portfolio all render for a stranger.
82
+ //
83
+ // /roster joins them (task 1003441): it is this project’s own ledger, and its
84
+ // one data source — GET /leaderboard — is deliberately unauthenticated,
85
+ // mirroring the public status surface. The page must not be gated tighter than
86
+ // the API it fronts, which is the same rule that puts /people at
87
+ // project.curate. It was reachable to a stranger as the #leaderboard view on
88
+ // the ungated index, so this preserves the audience rather than widening it.
89
+ '/builders/roster': null,
82
90
  '/builders/': null,
83
91
  '/builders/ranks': null,
84
92
  '/builders/drachmae': null,