@bongos/core 1.25.3 → 1.25.5

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.
Files changed (46) hide show
  1. package/.bongos-core.json +72 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +185 -3
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +17 -18
  10. package/docs/copy-registry.json +44 -43
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +43 -43
  13. package/modules/hall-ui/public/builders.js +3 -27
  14. package/modules/hall-ui/public/hall-render.js +5 -119
  15. package/modules/hall-ui/public/hall-widgets.js +3 -3
  16. package/modules/hall-ui/public/index.html +0 -7
  17. package/modules/hall-ui/public/shell-game.js +22 -4
  18. package/modules/hall-ui/public/style.css +3 -73
  19. package/modules/hall-ui/public/visor.css +31 -1
  20. package/modules/hall-ui/public/visor.js +53 -3
  21. package/modules/hall-ui/records/home-reshuffle.md +1 -1
  22. package/modules/hall-ui/records/visor.md +10 -0
  23. package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
  24. package/modules/provisioning/module.json +1 -0
  25. package/modules/provisioning/routes/provisioning.js +4 -0
  26. package/modules/provisioning/routes/update-subscription.js +51 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +16 -0
  30. package/scripts/bongos/update-channel.js +27 -4
  31. package/scripts/bongos/update-sweep.js +17 -3
  32. package/src/bongos/api-errors.js +2 -0
  33. package/src/bongos/db.js +43 -0
  34. package/src/bongos/route-rank-check.js +1 -0
  35. package/src/bongos/routes/builders.js +37 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/builder_card_requirement.mjs +75 -0
  38. package/tests/hall_board_world.mjs +7 -35
  39. package/tests/hall_home_ships_bounded.mjs +11 -27
  40. package/tests/hall_landing_boot.mjs +7 -8
  41. package/tests/hall_nav.mjs +2 -2
  42. package/tests/hall_visor.mjs +9 -2
  43. package/tests/hall_visor_profile.mjs +150 -0
  44. package/tests/leaderboard_roles.mjs +3 -17
  45. package/tests/provisioning_update_subscription_route.mjs +141 -0
  46. package/tests/update_channel_db.mjs +24 -0
package/src/bongos/db.js CHANGED
@@ -724,6 +724,48 @@ async function setBuilderTester({ builderId, isTester }) {
724
724
  });
725
725
  }
726
726
 
727
+ // setRequireCard — an Archon switches the hardware-card requirement on or off for one
728
+ // builder (task 1004748, ADR 0382 D3/D4). Switching ON needs at least one registered
729
+ // card, so nobody is locked out by the switch. The audit row is written in the SAME
730
+ // transaction as the flag, so a change cannot happen without its entry; unlike the
731
+ // generic request log it names the builder it was done TO.
732
+ async function setRequireCard({ builderId, required, actorBuilderId, ip = null, userAgent = null }) {
733
+ return withTx(async (client) => {
734
+ const { rows: found } = await client.query(
735
+ `SELECT id, system_role, require_card FROM builders WHERE id = $1 FOR UPDATE`,
736
+ [builderId]
737
+ );
738
+ const before = found[0];
739
+ if (!before) return null;
740
+ if (before.system_role) throw { code: 'TARGET_IS_SYSTEM_PRINCIPAL' };
741
+ if (required) {
742
+ const { rows: cards } = await client.query(
743
+ `SELECT 1 FROM passkey_credentials WHERE builder_id = $1 LIMIT 1 FOR SHARE`,
744
+ [builderId]
745
+ );
746
+ if (!cards[0]) throw { code: 'NO_CARD_REGISTERED' };
747
+ }
748
+ const { rows } = await client.query(
749
+ `UPDATE builders SET require_card = $2 WHERE id = $1
750
+ RETURNING id, github_login, display_name, rank, status, require_card`,
751
+ [builderId, !!required]
752
+ );
753
+ await client.query(
754
+ `INSERT INTO audit_log
755
+ (builder_id, route, method, request_body_redacted, response_status, ip, user_agent, surface)
756
+ VALUES ($1, $2, 'PATCH', $3, 200, $4, $5, NULL)`,
757
+ [
758
+ actorBuilderId,
759
+ `${require('./api-prefix').API_PREFIX}/builders/:id/card-requirement`,
760
+ { target_builder_id: String(builderId), require_card: !!required, was: before.require_card === true },
761
+ ip,
762
+ userAgent,
763
+ ]
764
+ );
765
+ return rows[0];
766
+ });
767
+ }
768
+
727
769
  async function reactivateBuilder({ builderId, actorBuilderId }) {
728
770
  return withTx(async (client) => {
729
771
  const { rows } = await client.query(
@@ -1183,6 +1225,7 @@ module.exports = {
1183
1225
  RANKS,
1184
1226
  setBuilderRank,
1185
1227
  setBuilderTester,
1228
+ setRequireCard,
1186
1229
  getLatestRankChangeForBuilder,
1187
1230
  deactivateBuilder,
1188
1231
  reactivateBuilder,
@@ -194,6 +194,7 @@ const EXPECTED_RANKS = {
194
194
  // builder authority — rank + lifecycle status mutation
195
195
  'PATCH /builders/:id/rank': 'perm:builder.rank.set',
196
196
  'PATCH /builders/:id/status': 'perm:builder.status.set',
197
+ 'PATCH /builders/:id/card-requirement': 'perm:builder.rank.set',
197
198
  // task 1004561: the Treasury desk sets a builder's monthly limit through it
198
199
  'PATCH /builders/:id/budget': 'perm:builder.budget.set',
199
200
  // session revocation (kill another builder's / all sessions)
@@ -421,6 +421,43 @@ module.exports = function buildBuildersRouter() {
421
421
  }
422
422
  });
423
423
 
424
+ // PATCH /builders/:id/card-requirement — an Archon switches the hardware-card
425
+ // requirement on or off for one builder (task 1004748, ADR 0382 D4). Body:
426
+ // { require_card: boolean }. Default is off for everyone. Switching ON refuses 409
427
+ // no_card_registered unless the builder has a registered card, so the switch cannot
428
+ // lock anyone out. You cannot switch OFF your own requirement (403
429
+ // cannot_disable_own_card_requirement) — a stolen session must not be able to drop
430
+ // it; another Archon does it. Every change writes an audit row naming the target.
431
+ // rank: archon. Gate: requirePermission('builder.rank.set') (the non-delegable atom that
432
+ // guards the step-up's own target route) then requireCardStepUp; read fresh per request.
433
+ router.patch('/builders/:id/card-requirement', auth.requireBuilder, auth.requirePermission('builder.rank.set'), requireCardStepUp, async (req, res) => {
434
+ if (validateOrRespond(req, res, { require_card: { required: true, type: 'boolean' } })) return;
435
+ const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
436
+ if (targetId === null) return;
437
+ const required = req.body.require_card;
438
+ if (!required && String(targetId) === String(req.builder.id)) {
439
+ return res.fail('cannot_disable_own_card_requirement', 403);
440
+ }
441
+ try {
442
+ const builder = await db.setRequireCard({
443
+ builderId: targetId,
444
+ required,
445
+ actorBuilderId: req.builder.id,
446
+ ip: req.ip || null,
447
+ userAgent: req.headers['user-agent'] || null,
448
+ });
449
+ if (!builder) return res.fail('builder_not_found', 404);
450
+ res.json({ builder });
451
+ } catch (err) {
452
+ if (err && err.code === 'TARGET_IS_SYSTEM_PRINCIPAL') return res.fail('target_is_system_principal', 403);
453
+ if (err && err.code === 'NO_CARD_REGISTERED') {
454
+ return res.fail('no_card_registered', 409, { message: 'That builder has no registered card yet; they must register one before the requirement can be switched on.' });
455
+ }
456
+ log.error('[gds] PATCH /builders/:id/card-requirement', err);
457
+ res.fail('card_requirement_change_failed', { status: 500, message: 'internal error' });
458
+ }
459
+ });
460
+
424
461
  // PATCH /builders/:id/capabilities — the owner gives or takes a builder's server
425
462
  // access (task 1004950, ADR 0395). Body: { server: boolean }. A task that needs
426
463
  // the server is shown to and claimable by a builder only while this is on.
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.25.3'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.5'; // 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,75 @@
1
+ // tests/builder_card_requirement.mjs
2
+ //
3
+ // Task 1004748 (ADR 0382 D3/D4) — an Archon switches the hardware-card requirement on
4
+ // per builder. DB-free: the shared pool's client is a recorder.
5
+ //
6
+ // Run: node tests/builder_card_requirement.mjs
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+ import { makeRunner } from './helpers.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { pool } = require('../src/bongos/pool');
14
+ const { test, summary } = makeRunner();
15
+
16
+ let builderRow; let hasCard; const sent = [];
17
+ pool.connect = async () => ({
18
+ query: async (sql, params = []) => {
19
+ const q = String(sql);
20
+ sent.push({ sql: q, params });
21
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(q)) return { rows: [] };
22
+ if (/FROM builders WHERE id/.test(q)) return { rows: builderRow ? [builderRow] : [] };
23
+ if (/FROM passkey_credentials/.test(q)) return { rows: hasCard ? [{}] : [] };
24
+ if (/UPDATE builders SET require_card/.test(q)) return { rows: [{ id: params[0], require_card: params[1] }] };
25
+ return { rows: [] };
26
+ },
27
+ release() {},
28
+ });
29
+
30
+ const { setRequireCard } = require('../src/bongos/db');
31
+ const reset = () => { sent.length = 0; };
32
+
33
+ await test('switching on refuses a builder with no registered card, writes nothing', async () => {
34
+ reset(); builderRow = { id: '7', system_role: null, require_card: false }; hasCard = false;
35
+ await assert.rejects(setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' }), { code: 'NO_CARD_REGISTERED' });
36
+ assert.equal(sent.some((s) => /UPDATE builders|INSERT INTO audit_log/.test(s.sql)), false);
37
+ });
38
+
39
+ await test('switching on with a card sets the flag and audits the target in the same transaction', async () => {
40
+ reset(); hasCard = true;
41
+ const r = await setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' });
42
+ assert.equal(r.require_card, true);
43
+ const audit = sent.find((s) => /INSERT INTO audit_log/.test(s.sql));
44
+ assert.ok(audit);
45
+ assert.equal(audit.params[0], '1');
46
+ assert.deepEqual(audit.params[2], { target_builder_id: '7', require_card: true, was: false });
47
+ });
48
+
49
+ await test('switching off needs no card and is audited', async () => {
50
+ reset(); hasCard = false; builderRow = { id: '7', system_role: null, require_card: true };
51
+ const r = await setRequireCard({ builderId: '7', required: false, actorBuilderId: '1' });
52
+ assert.equal(r.require_card, false);
53
+ assert.deepEqual(sent.find((s) => /INSERT INTO audit_log/.test(s.sql)).params[2].was, true);
54
+ });
55
+
56
+ await test('unknown builder is null; a system principal is refused', async () => {
57
+ reset(); builderRow = null;
58
+ assert.equal(await setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), null);
59
+ builderRow = { id: '9', system_role: 'runner', require_card: false };
60
+ await assert.rejects(setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), { code: 'TARGET_IS_SYSTEM_PRINCIPAL' });
61
+ });
62
+
63
+ await test('route: rank atom + step-up gate, self-disable refused, 409 mapped, errors classified', async () => {
64
+ const fs = await import('node:fs');
65
+ const src = fs.readFileSync(new URL('../src/bongos/routes/builders.js', import.meta.url), 'utf8');
66
+ const line = src.split(/\r?\n/).find((l) => l.includes("router.patch('/builders/:id/card-requirement'"));
67
+ assert.match(line, /requirePermission\('builder\.rank\.set'\), requireCardStepUp/);
68
+ assert.match(src, /cannot_disable_own_card_requirement', 403/);
69
+ assert.match(src, /NO_CARD_REGISTERED[\s\S]{0,80}no_card_registered', 409/);
70
+ const { classify } = require('../src/bongos/api-errors');
71
+ assert.equal(classify('no_card_registered'), 'conflict');
72
+ assert.equal(classify('cannot_disable_own_card_requirement'), 'forbidden');
73
+ });
74
+
75
+ summary();
@@ -270,23 +270,6 @@ test('the band ends level: no align-items on the grid, so the two cards stretch'
270
270
  'and the single-column fallback still exists below 768px (the task text said 860; the sheet says 768)');
271
271
  });
272
272
 
273
- test('the Standing card is the mock\'s: an identity line and a three-row fact ledger, no banner', () => {
274
- const block = hallRenderJs.match(/const homeStanding = document\.getElementById\('home-standing-body'\);[\s\S]*?renderSystemNotices\(/);
275
- assert.ok(block, 'the Home card render is where it was');
276
- assert.match(block[0], /class="home-who"/, 'the identity line');
277
- assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
278
- 'the currency row takes its label from the branding contract at render time — a data-currency-label attribute is rebranded once at load, before this row exists');
279
- for (const label of ['Leaderboard place', 'Preferred work']) {
280
- assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
281
- }
282
- assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
283
- assert.doesNotMatch(block[0], /Banner\}/, 'no banner is stacked into the card any more');
284
- for (const sel of ['.home-who', '.home-facts', '.home-facts dt', '.home-facts dd']) {
285
- assert.ok(rule(styleCss, sel), `style.css styles ${sel}`);
286
- }
287
- assert.match(rule(styleCss, '.home-who__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
288
- });
289
-
290
273
  test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
291
274
  // Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
292
275
  const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
@@ -322,7 +305,7 @@ test('the greeting reads /me only; the version tab and its progress read left Ho
322
305
 
323
306
  // Boot hall-render.js against a stub DOM and a ctx of getters (the way
324
307
  // builders.js builds it) and read what the greeting and the card actually say.
325
- function bootHallRender({ me, progress = null, leaderboard = [], ctxExtra = {} }) {
308
+ function bootHallRender({ me, progress = null, ctxExtra = {} }) {
326
309
  const els = new Map();
327
310
  const makeEl = () => ({
328
311
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
@@ -364,7 +347,6 @@ function bootHallRender({ me, progress = null, leaderboard = [], ctxExtra = {} }
364
347
  ...ctxExtra,
365
348
  };
366
349
  const api = windowObj.OTBHallRender(ctx);
367
- api.lbState.rows = leaderboard;
368
350
  return { api, el: (id) => documentObj.getElementById(id) };
369
351
  }
370
352
 
@@ -374,7 +356,6 @@ const ME = {
374
356
  cost: { level: 'warn', spend_mtd_usd: 80, budget_mtd_usd: 100, pct_used: 80 },
375
357
  needs_rebase_tasks: [{ id: 1003460 }],
376
358
  };
377
- const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
378
359
  const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
379
360
 
380
361
  test('RUN: the greeting names the builder, and writes nothing else', () => {
@@ -386,22 +367,13 @@ test('RUN: the greeting names the builder, and writes nothing else', () => {
386
367
  assert.equal(el('home-version').textContent || '', '', 'no version tab is drawn');
387
368
  });
388
369
 
389
- test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
390
- const { api, el } = bootHallRender({ me: ME, leaderboard: LEADERBOARD });
370
+ test('RUN: Home draws no Standing chip, your details are on the visor now (task 1005249)', () => {
371
+ const { api, el } = bootHallRender({ me: ME });
372
+ assert.equal(api.lbState, undefined, 'no leaderboard rows are kept for a place nobody shows');
373
+ assert.equal(api.setRecentShips, undefined, 'no shipped count is kept for a chip that is gone');
391
374
  api.renderProfile(ME.builder);
392
- const html = el('home-standing-body').innerHTML;
393
- assert.match(html, /<p class="home-who__name">Percy Jackson<\/p>/);
394
- assert.match(html, /<p class="home-who__handle">@percy<\/p>/);
395
- // written from curLabel(true), not a data-currency-label attribute: brand-apply
396
- // rebrands those once at load, and this row is stamped after /me answers
397
- assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
398
- assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
399
- assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
400
- assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
401
- const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
402
- withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['artisan', 'ui'] });
403
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>artisan, ui<\/dd>/);
404
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
375
+ // the stub document hands back a blank element for any id, so "drew nothing" is an empty one
376
+ assert.equal(el('home-standing-body').innerHTML || '', '', 'no Standing card is written');
405
377
  });
406
378
 
407
379
  // task 1005046: "Where you stand" left the retired Ranks page for Accomplishments,
@@ -7,9 +7,8 @@
7
7
  // table and a count. This pins the bounded shape:
8
8
  //
9
9
  // 1. the route answers a small page plus `total`, counting only when it must;
10
- // 2. hall-render.js keeps Home on that small page, and fetches the full ledger
11
- // only when the Accomplishments view is open — and again only when the
12
- // newest ships or the count moved.
10
+ // 2. Home asks for no ships at all (task 1005249 removed the last count it
11
+ // showed); the Profile page reads its own ledger.
13
12
  //
14
13
  // (builders.js's own request is pinned in tests/hall_landing_boot.mjs, which
15
14
  // already boots the real page.) DB-free: vm + stubs.
@@ -120,31 +119,16 @@ function loadRender({ view = 'home' } = {}) {
120
119
  return { r, el, fetched, showView };
121
120
  }
122
121
 
123
- const homePage = (total, firstId = 3000) => ({ ships: [ship(firstId), ship(firstId - 1)], total });
124
-
125
- test('Home: the Standing chip\'s count comes from the small page, with no ledger fetch', async () => {
126
- // task 1005054: the Shipped tile and the five-row table left Home with the stats
127
- // strip and "Your work"; the small page still feeds the Standing chip's count.
128
- const { r, el, fetched } = loadRender();
129
- assert.equal(r.HOME_SHIPS_LIMIT, 5);
130
- r.setRecentShips(homePage(3000));
131
- await new Promise((done) => setTimeout(done, 0));
132
- assert.deepEqual(fetched, [], 'Home never asks for the full history');
133
- assert.equal(el('standing-shipped').textContent, '3000', 'the chip shows the server total, not rows held');
134
- assert.equal(el('recent-ships tbody').innerHTML, '', 'the ledger is untouched until its view opens');
135
- });
136
-
137
- // task 1005050: the full ledger was the Accomplishments view's, and that view
138
- // folded into the Profile page (whose Works ledger reads its own record). Home
139
- // now NEVER asks for the whole history — not on a view change, not on a refresh.
140
- test('Home never downloads the whole history, whatever view event or refresh arrives', async () => {
141
- const { r, el, fetched, showView } = loadRender({ view: 'standing' });
142
- r.setRecentShips(homePage(3000));
122
+ // task 1005249: the Standing chip, the last thing on Home that showed a shipped
123
+ // count, is gone, so Home keeps no ships at all — not a small page, not a count —
124
+ // and asks for none, whatever view event arrives.
125
+ test('Home keeps and asks for no ships at all, whatever view event arrives', async () => {
126
+ const { r, fetched, showView } = loadRender({ view: 'home' });
127
+ assert.equal(r.setRecentShips, undefined, 'no ships setter');
128
+ assert.equal(r.HOME_SHIPS_LIMIT, undefined, 'no small page size');
129
+ assert.equal(r.loadShipLedger, undefined, 'no ledger loader');
143
130
  showView('standing');
144
- r.setRecentShips(homePage(3001, 3001)); // this builder shipped
145
131
  await new Promise((done) => setTimeout(done, 0));
146
- assert.deepEqual(fetched, [], 'no ?limit=all read from Home');
147
- assert.equal(el('standing-shipped').textContent, '3001', 'the chip follows the newest total');
148
- assert.equal(r.loadShipLedger, undefined, 'the ledger loader went with the view');
132
+ assert.deepEqual(fetched, [], 'no ships read from Home');
149
133
  });
150
134
 
@@ -91,10 +91,9 @@ async function boot({ meStatus, settle = false }) {
91
91
  wirePager: () => {},
92
92
  },
93
93
  OTBHallRender: () => ({
94
- analyticsState: {}, lbState: {},
94
+ analyticsState: {},
95
95
  shipsPager: { page: 1 },
96
- // task 1004854: Home asks for this many ships; the ledger's full read lives in hall-render.js.
97
- HOME_SHIPS_LIMIT: 5, setRecentShips: spy('setRecentShips'), populateShipVersionFilter: spy('populateShipVersionFilter'),
96
+ populateShipVersionFilter: spy('populateShipVersionFilter'),
98
97
  mountHallWidgets: spy('mountHallWidgets'),
99
98
  renderLanding: spy('renderLanding'),
100
99
  renderCurveBody: spy('renderCurveBody'), renderGreeting: spy('renderGreeting'),
@@ -154,10 +153,10 @@ test('a signed-in builder still gets the hall', async () => {
154
153
 
155
154
  // task 1004854: Home used to download the builder's WHOLE ship history here, on
156
155
  // every load and every live refresh — ~3.4 s of database time for 3,000 ships, to
157
- // fill a five-row table and a count. It asks for the small page only.
158
- test('Home asks for its few newest ships, never the full history', async () => {
156
+ // fill a five-row table and a count. Since task 1005249 it shows no ship count at
157
+ // all (the Standing chip went), so it asks for no ships and no leaderboard.
158
+ test('Home asks for no ships and no leaderboard', async () => {
159
159
  const calls = await boot({ meStatus: 200, settle: true });
160
- const ships = calls.requests.filter((r) => r.includes('/me/recent-ships'));
161
- assert.deepEqual(ships, ['GET /api/bongos/me/recent-ships?limit=5'], `ship requests were: ${ships.join(', ') || 'none'}`);
162
- assert.ok(calls.rendered.includes('setRecentShips'), 'the small page is handed to the renderer');
160
+ const reads = calls.requests.filter((r) => r.includes('/me/recent-ships') || r.includes('/leaderboard'));
161
+ assert.deepEqual(reads, [], `Home still read: ${reads.join(', ')}`);
163
162
  });
@@ -905,9 +905,9 @@ test('Accomplishments is gone from the sidebar, and every old link lands on your
905
905
  }
906
906
  // No view is left for them to open on the index.
907
907
  assert.ok(!/id="view-standing"/.test(fs.readFileSync(path.join(HALL, 'index.html'), 'utf8')), 'the view is gone');
908
- // Home's Standing card still names the shipped count and links to the profile.
908
+ // Home's Standing card went in task 1005249: the visor's you-block opens the profile now
909
+ // (tests/hall_visor_profile.mjs runs it).
909
910
  const render = fs.readFileSync(path.join(HALL, 'hall-render.js'), 'utf8');
910
- assert.match(render, /View full profile →/);
911
911
  assert.ok(!/\$\('#profile-body'\)/.test(render), 'the Accomplishments band is not drawn any more');
912
912
  const fn = render.slice(render.indexOf(' function renderProfile(builder) {'), render.indexOf(' function renderSystemNotices('));
913
913
  assert.match(fn, /renderSystemNotices\(\{ rebaseTasks, need, actionNeeds, cost \}\)/, 'Home still gets every notice');
@@ -503,9 +503,16 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
503
503
  assert.equal(g.placeShadow, 'none', 'type on a plate needs no halo');
504
504
  assert.equal(g.radius, '0px', 'no round thing on the glass');
505
505
  assert.match(g.spriteClip, /^polygon\(/, 'the sprite sits in a cut box');
506
+ // task 1005249: you (left of the foot row) are a link to your profile now, so the
507
+ // stops run in reading order: you, then the accent button. Both take the inset ring.
508
+ const focused = () => page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
506
509
  await page.keyboard.press('Tab');
507
- const f = await page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
508
- assert.equal(f.cls, 'visor__main', 'the accent button is the first stop');
510
+ const me = await focused();
511
+ assert.equal(me.cls, 'visor__me', 'you are the first stop, reading left to right');
512
+ assert.match(me.ring, /inset/, 'you take the inset ring too');
513
+ await page.keyboard.press('Tab');
514
+ const f = await focused();
515
+ assert.equal(f.cls, 'visor__main', 'the accent button is the next stop');
509
516
  assert.equal(f.outline, 'none');
510
517
  assert.match(f.ring, /inset/, 'focus is the inset two-tone ring (--press-focus)');
511
518
  await ctx.close();
@@ -0,0 +1,150 @@
1
+ // tests/hall_visor_profile.mjs — you on the visor (task 1005249).
2
+ //
3
+ // The owner's ask (2026-11-12): the Standing chip that sat top right of Home is gone;
4
+ // the visor's bottom-left you-block shows your details on hover and opens your whole
5
+ // profile, in the game's window over the place, on click. This runs the real visor,
6
+ // the real shell-game window and the real dialog in a browser, plus the pure facts in
7
+ // a vm, and checks Home's page no longer carries the chip.
8
+ //
9
+ // Run: node --test tests/hall_visor_profile.mjs
10
+ import assert from 'node:assert/strict';
11
+ import { test, after } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
21
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
22
+
23
+ // ---------- the facts, in a vm ----------
24
+ function loadVisor(me) {
25
+ const doc = {
26
+ readyState: 'complete', body: null, documentElement: { dataset: {}, classList: { contains: () => false, toggle() {}, remove() {} } },
27
+ addEventListener() {}, querySelector: () => null, createElement: () => ({}),
28
+ };
29
+ const win = { document: doc, location: { pathname: '/', assign() {} }, addEventListener() {}, OTBCraftMode: { me: () => me } };
30
+ win.window = win;
31
+ vm.runInNewContext(read('visor.js'), win);
32
+ return win.OTBVisor;
33
+ }
34
+
35
+ test('the card\'s facts come from /me: name, handle, rank, credits, karma, preferred work', () => {
36
+ const me = { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96.4, preferred_disciplines: ['artisan', 'ui'] } };
37
+ const f = JSON.parse(JSON.stringify(loadVisor(me).meFacts(me)));
38
+ assert.deepEqual(f, { name: 'Percy Jackson', handle: '@percy', rank: 'Metic', credits: 14441, karma: 96, prefs: 'artisan, ui' });
39
+ const bare = JSON.parse(JSON.stringify(loadVisor(null).meFacts({ builder: { github_login: 'x' } })));
40
+ assert.equal(bare.name, 'x', 'no display name: the login');
41
+ assert.equal(bare.karma, 0);
42
+ assert.equal(bare.prefs, '', 'no preference: empty, and the card says "Not set yet"');
43
+ assert.equal(loadVisor(null).meFacts(null), null, 'before /me answers there is nothing to show');
44
+ });
45
+
46
+ test('Home carries no Standing chip any more, and no sheet styles one', () => {
47
+ assert.doesNotMatch(read('index.html'), /hall-standing|home-standing-body/);
48
+ assert.doesNotMatch(read('style.css'), /\.hall-standing|\.home-who|\.home-facts/);
49
+ assert.doesNotMatch(read('hall-render.js'), /home-standing-body|hall-standing|leaderboardPlace/);
50
+ });
51
+
52
+ // ---------- in a browser ----------
53
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
54
+ let browser = null;
55
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
56
+ after(() => browser && browser.close());
57
+ const skip = !browser && 'no system Edge or Chrome';
58
+
59
+ const ORIGIN = 'http://hall.test';
60
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' };
61
+ const SCENE = `<script>
62
+ window.OTBCraftMode = { character() { return 'artisan'; }, current() { return null; },
63
+ me() { return { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96, preferred_disciplines: [] } }; },
64
+ spriteSrc(id) { return '/builders/characters/' + id + '.png'; } };
65
+ window.__visorScene = { name: 'test', place: 'Test city', actions: [], keys: [] };
66
+ </script>`;
67
+ const PAGE = (withWindow) => `<!doctype html><html data-theme="dark" data-faces="helm"><head><meta charset="utf-8">
68
+ <link rel="stylesheet" href="/builders/style.css"><link rel="stylesheet" href="/builders/visor.css"></head>
69
+ <body>${SCENE}<script src="/builders/hall-dialog.js"></script>${withWindow ? '<script src="/builders/shell-game.js"></script>' : ''}<script src="/builders/visor.js"></script></body></html>`;
70
+
71
+ async function open({ withWindow = true } = {}) {
72
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
73
+ await ctx.route(ORIGIN + '/**', (route) => {
74
+ const p = new URL(route.request().url()).pathname;
75
+ if (p === '/') return route.fulfill({ contentType: 'text/html', body: PAGE(withWindow) });
76
+ if (p === '/profile') return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>profile</title><p id="pp">your profile</p><script src="/builders/shell-game.js"></script>' }); // the real page carries the game shell, which hands an Escape up
77
+ const f = path.join(HALL, p.replace(/^\/builders\//, ''));
78
+ if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
79
+ return route.fulfill({ status: 404, body: '' });
80
+ });
81
+ const page = await ctx.newPage();
82
+ const errors = [];
83
+ page.on('pageerror', (e) => errors.push(String(e.message)));
84
+ await page.addInitScript(() => { try { localStorage.setItem('hall-game', 'on'); document.documentElement.dataset.game = 'on'; window.OTB = { escapeHtml: (s) => String(s) }; } catch (_) { /* none */ } });
85
+ await page.goto(ORIGIN + '/');
86
+ await page.waitForFunction(() => !!document.querySelector('.visor__me'));
87
+ return { ctx, page, errors };
88
+ }
89
+
90
+ test('hovering you on the visor shows your details; at rest they are hidden', { skip }, async () => {
91
+ const { ctx, page, errors } = await open();
92
+ const shown = () => page.evaluate(() => getComputedStyle(document.querySelector('.visor__card')).visibility);
93
+ assert.equal(await shown(), 'hidden', 'nothing over the scene at rest');
94
+ await page.hover('.visor__me');
95
+ await page.waitForFunction(() => getComputedStyle(document.querySelector('.visor__card')).opacity === '1');
96
+ assert.equal(await shown(), 'visible');
97
+ const text = await page.evaluate(() => [...document.querySelectorAll('.visor__card > span')].map((s) => s.innerText.replace(/\s+/g, ' ').trim()));
98
+ assert.match(text[0], /Percy Jackson @percy/i);
99
+ assert.match(text[1], /Rank Metic/i);
100
+ assert.match(text[1], /Credits 14,441/i);
101
+ assert.match(text[1], /Karma 96/i);
102
+ assert.match(text[1], /Preferred work Not set yet/i);
103
+ assert.match(text[2], /Open your profile/i);
104
+ const box = await page.evaluate(() => { const r = document.querySelector('.visor__card').getBoundingClientRect(); return { top: r.top, left: r.left }; });
105
+ assert.ok(box.top >= 0 && box.left >= 0, 'the card stays on screen');
106
+ assert.deepEqual(errors, []);
107
+ await ctx.close();
108
+ });
109
+
110
+ test('clicking you opens your whole profile in the window over the place; Escape closes it', { skip }, async () => {
111
+ const { ctx, page, errors } = await open();
112
+ await page.click('.visor__me');
113
+ await page.waitForFunction(() => window.OTBOptionsOverlay.isOpen());
114
+ const w = await page.evaluate(() => ({
115
+ url: location.pathname,
116
+ src: document.querySelector('.opt-window__frame').getAttribute('src'),
117
+ label: document.querySelector('.opt-window__box').getAttribute('aria-label'),
118
+ close: document.querySelector('.opt-window__x').getAttribute('aria-label'),
119
+ }));
120
+ assert.equal(w.url, '/', 'the place stays: no navigation');
121
+ assert.equal(w.src, '/profile?embed=1');
122
+ assert.equal(w.label, 'Your profile');
123
+ assert.equal(w.close, 'Close your profile');
124
+ // the frame's own shell must be up before an Escape inside it can be handed up
125
+ await page.waitForFunction(() => { const f = document.querySelector('.opt-window__frame'); try { return !!(f && f.contentDocument.readyState === 'complete' && f.contentDocument.documentElement.dataset.embed === 'on'); } catch (_) { return false; } });
126
+ await page.keyboard.press('Escape');
127
+ await page.waitForFunction(() => !window.OTBOptionsOverlay.isOpen());
128
+ // the same window still opens Options under its own name
129
+ await page.evaluate(() => window.OTBOptionsOverlay.open(''));
130
+ assert.equal(await page.evaluate(() => document.querySelector('.opt-window__box').getAttribute('aria-label')), 'Options');
131
+ assert.equal(await page.evaluate(() => document.querySelector('.opt-window__frame').getAttribute('src')), '/settings?embed=1');
132
+ assert.deepEqual(errors, []);
133
+ await ctx.close();
134
+ });
135
+
136
+ test('with no window to draw, you are still a link to your profile', { skip }, async () => {
137
+ const { ctx, page, errors } = await open({ withWindow: false });
138
+ const r = await page.evaluate(() => {
139
+ const me = document.querySelector('.visor__me');
140
+ const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
141
+ // stop the browser before it leaves, once the visor has had its say
142
+ window.addEventListener('click', (e) => { window.__left = !e.defaultPrevented; e.preventDefault(); });
143
+ me.querySelector('.visor__name').dispatchEvent(ev);
144
+ return { href: me.getAttribute('href'), followed: window.__left };
145
+ });
146
+ assert.equal(r.href, '/profile');
147
+ assert.equal(r.followed, true, 'the visor lets the link take you to the profile page');
148
+ assert.deepEqual(errors, []);
149
+ await ctx.close();
150
+ });
@@ -16,7 +16,7 @@
16
16
  // that craft earned, and a dash, not a 0, for a builder with no AI sessions.
17
17
  // (Two crafts since task 1005141: the engineer is the artisan, and the
18
18
  // ideator folded into the artist, so idea credits rank as artist credits.)
19
- // 4. THE STANDING CARD and 5. THE STATUS PAGE: the real functions, run.
19
+ // 5. THE STATUS PAGE: the real function, run. (4, the Standing chip, went in task 1005249.)
20
20
 
21
21
  import { strict as assert } from 'node:assert';
22
22
  import { createRequire } from 'node:module';
@@ -234,7 +234,7 @@ await test('the Artists toggle shows only artists, ranked by the credits that cr
234
234
  assert.equal(r.rows.length, FIX.leaderboard.length, 'Everyone brings every builder back');
235
235
  });
236
236
 
237
- // ---- 4. the standing card ---------------------------------------------------------
237
+ // ---- 4. (the standing card, gone; extract() serves section 5) ---------------------
238
238
 
239
239
  function extract(src, start, end) {
240
240
  const a = src.indexOf(start);
@@ -243,21 +243,7 @@ function extract(src, start, end) {
243
243
  return src.slice(a, b);
244
244
  }
245
245
 
246
- await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
247
- const src = read('modules/hall-ui/public/hall-render.js');
248
- const code = extract(src, 'const lbState = { rows: [] };', ' return { lbState');
249
- const sandbox = { rows: FIX.leaderboard };
250
- vm.createContext(sandbox);
251
- vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
252
- const art = sandbox.place('builder-7');
253
- assert.equal(art.role.craft, 'artist');
254
- assert.equal(sandbox.text(art), `#2 as an artist`, 'fourth overall, second among artists: something new, so it is said');
255
- const top = sandbox.place(FIX.leaderboard[0].github_login);
256
- assert.equal(sandbox.text(top), '', 'the top artisan is #1 either way, so nothing is added');
257
- // task 1005054: the stats strip (and its rank tile) left Home; the Standing chip's card carries the role place
258
- assert.match(src, /\$\{rolePlaceText\(rollPlace\) \? `<span title="Your place among the/);
259
- assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
260
- });
246
+ // (Its test of the Standing chip's role place went with the chip in task 1005249.)
261
247
 
262
248
  // ---- 5. the status page -----------------------------------------------------------
263
249