@bongos/core 1.21.22 → 1.21.24

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 (58) hide show
  1. package/.bongos-core.json +62 -62
  2. package/docs/copy-inventory.md +128 -123
  3. package/docs/copy-registry.json +222 -187
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +251 -248
  6. package/modules/hall-ui/public/atlas.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/board-room.html +1 -1
  9. package/modules/hall-ui/public/city.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/commands.html +1 -1
  12. package/modules/hall-ui/public/copy-desk.html +1 -1
  13. package/modules/hall-ui/public/deploy.html +1 -1
  14. package/modules/hall-ui/public/diagrams.html +1 -1
  15. package/modules/hall-ui/public/drachmae.html +2 -2
  16. package/modules/hall-ui/public/fleet.html +1 -1
  17. package/modules/hall-ui/public/gate.html +1 -1
  18. package/modules/hall-ui/public/goal-map.html +1 -1
  19. package/modules/hall-ui/public/goals.html +1 -1
  20. package/modules/hall-ui/public/government.html +1 -1
  21. package/modules/hall-ui/public/idea.html +5 -7
  22. package/modules/hall-ui/public/ideas.html +1 -1
  23. package/modules/hall-ui/public/index.html +1 -1
  24. package/modules/hall-ui/public/modules.html +1 -1
  25. package/modules/hall-ui/public/primer.html +2 -2
  26. package/modules/hall-ui/public/profile.html +1 -1
  27. package/modules/hall-ui/public/project-settings.html +1 -1
  28. package/modules/hall-ui/public/ranks.html +1 -1
  29. package/modules/hall-ui/public/record.css +4 -19
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/roster.html +1 -1
  32. package/modules/hall-ui/public/sessions.css +16 -0
  33. package/modules/hall-ui/public/sessions.html +14 -2
  34. package/modules/hall-ui/public/sessions.js +111 -3
  35. package/modules/hall-ui/public/settings.html +1 -1
  36. package/modules/hall-ui/public/shell.js +56 -5
  37. package/modules/hall-ui/public/street.html +1 -1
  38. package/modules/hall-ui/public/studio.html +1 -1
  39. package/modules/hall-ui/public/task.html +6 -9
  40. package/modules/hall-ui/public/task.js +15 -50
  41. package/modules/hall-ui/public/task.states.json +1 -1
  42. package/modules/hall-ui/public/thinking.html +1 -1
  43. package/modules/hall-ui/public/tweak-editor.css +4 -0
  44. package/modules/hall-ui/public/tweak-editor.html +5 -3
  45. package/modules/hall-ui/public/watch.html +1 -1
  46. package/modules/hall-ui/public/work.html +1 -1
  47. package/modules/hall-ui/records/hall-navigation.md +7 -4
  48. package/modules/sessions/db.js +4 -0
  49. package/package-lock.json +2 -2
  50. package/package.json +1 -1
  51. package/release-notes.json +12 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/hall_fullscreen.mjs +88 -12
  54. package/tests/hall_record_world.mjs +41 -63
  55. package/tests/hall_sessions_ledger.mjs +40 -0
  56. package/tests/hall_tweak_editor.mjs +3 -2
  57. package/tests/nav_permission_atoms.mjs +1 -1
  58. package/tests/session_transcript.mjs +5 -0
@@ -306,7 +306,8 @@ const CLAIM_ACTION_SRC = read('claim-action.js');
306
306
  // all of them, because what these tests are actually about is the REQUEST the
307
307
  // page issues — `requests` carries paths only and is left alone so the older
308
308
  // assertions on it keep meaning what they meant.
309
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
309
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims, shell = false } = {}) {
310
+ const fills = []; // task 1004477: each time the page asked the shell to fill its exits
310
311
  const requests = [];
311
312
  const writes = [];
312
313
  const toasts = [];
@@ -400,6 +401,9 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
400
401
  confirm: () => true,
401
402
  navigator: { clipboard: { writeText: async () => {} } },
402
403
  };
404
+ // task 1004477: the shell is what fills a [data-shell-exit] link. `shell: true`
405
+ // stands it in so a test can see the page ask; absent, the page must still work.
406
+ if (shell) windowObj.OTBShell = { fillExits(root) { fills.push(root); } };
403
407
  class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
404
408
  const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
405
409
  sandbox.globalThis = sandbox;
@@ -410,6 +414,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
410
414
  assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
411
415
  return {
412
416
  boot: onReady,
417
+ fills,
413
418
  html: () => documentObj.getElementById('task-body').innerHTML,
414
419
  sub: () => documentObj.getElementById('task-sub').textContent,
415
420
  el: (id) => documentObj.getElementById(id),
@@ -536,77 +541,50 @@ test('the idea grade badge IS the archetype pill, not a private copy of it', ()
536
541
  'idea.css carried a near-verbatim copy of .rec-pill down to the token triples — a hundred lines waiting to drift');
537
542
  });
538
543
 
539
- // ---- 10. the way out — the record exits to where it was opened from ----------
544
+ // ---- 10. the way out — the record exits through the shell's Back ------------
540
545
  //
541
- // task 1003468. task.js hardcoded "Open the work board" in three places and
542
- // task.html's crumb a fourth, so a builder who walked goal → task had only the
543
- // browser's own Back, and the two failure renders sent them somewhere they had
544
- // never been. The list that opens a record now writes `?from=` on the link
545
- // (task-detail.js's href()) and the record offers THAT as its exit — in the
546
- // crumb before the fetch lands, and in the actions row of every render
547
- // including the two failures. A same-origin referrer is the fallback for a link
548
- // written without it; the board is the fallback for a reader with no history.
549
- // Every value is honoured only as a path on this host.
550
-
551
- const FROM_GOAL = '?from=' + encodeURIComponent('/goals#/goal/1000081');
552
-
553
- test('opened from a goal, the record exits to that goal — crumb and actions row alike', async () => {
554
- const r = renderTask(BASE_TASK, { search: FROM_GOAL });
555
- await r.boot(); await settle();
556
- assert.match(r.html(), /<a class="td-btn" href="\/goals#\/goal\/1000081">Back to goal 1000081<\/a>/);
557
- assert.doesNotMatch(r.html(), /Open the work board/, 'the hardcoded exit is gone from the happy path');
558
- assert.equal(r.el('task-back').href, '/goals#/goal/1000081', 'the crumb points at the goal');
559
- assert.equal(r.el('task-back-label').textContent, 'Back to goal 1000081');
560
- });
561
-
562
- test('opened from the board, the exit is the board — on the tab it was opened from', async () => {
563
- const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/work#backlog') });
564
- await r.boot(); await settle();
565
- assert.match(r.html(), /href="\/work#backlog">Back to the work board</);
566
- });
567
-
568
- test('without ?from=, a same-origin referrer is the way back', async () => {
569
- const r = renderTask(BASE_TASK, { referrer: 'https://builders.cloudbongos.com/sessions?page=2' });
570
- await r.boot(); await settle();
571
- assert.match(r.html(), /href="\/sessions\?page=2">Back to sessions</);
572
- });
573
-
574
- test('a cross-origin referrer, a broken one, or none, falls back to the work board', async () => {
575
- for (const referrer of ['', 'https://example.org/elsewhere', 'not a url']) {
576
- const r = renderTask(BASE_TASK, { referrer });
546
+ // task 1003468 taught the record to exit to where it was opened from; task
547
+ // 1004477 moved that decision into shell.js, which now makes it for every hall
548
+ // page (tests/hall_fullscreen.mjs drives the real shell through ?from=, the
549
+ // referrer, the hostile values and the fallback). What stays the record's is
550
+ // PLACING the exit: an actions-row link in every render, the two failures
551
+ // included, marked [data-shell-exit] so the shell fills it with the same
552
+ // address and words as its top-bar Back — and asking the shell to, once the
553
+ // render that placed it has landed.
554
+
555
+ const EXIT_SLOT = /<a class="td-btn" data-shell-exit href="\/work">Back to tasks<\/a>/;
556
+
557
+ test('every render places one shell exit — the record and both honest failures', async () => {
558
+ const cases = [['record', 200], ['not found', 404], ['feed unavailable', 500]];
559
+ for (const [name, status] of cases) {
560
+ const r = renderTask(BASE_TASK, { status, shell: true });
577
561
  await r.boot(); await settle();
578
- assert.match(r.html(), /href="\/work">Back to the work board</, `referrer ${JSON.stringify(referrer)}`);
579
- assert.equal(r.el('task-back').href, '/work');
562
+ assert.equal((r.html().match(/data-shell-exit/g) || []).length, 1, `${name}: one exit`);
563
+ assert.match(r.html(), EXIT_SLOT, `${name}: the static href is what a reader without the shell still gets`);
564
+ assert.doesNotMatch(r.html(), /Open the work board/, `${name}: the old hardcoded exit stays gone`);
565
+ assert.ok(r.fills.length >= 1, `${name}: the page asked the shell to fill the exit it placed`);
580
566
  }
581
567
  });
582
568
 
583
- test('a hostile or useless ?from= is refused, never followed', async () => {
584
- const bad = [
585
- '//evil.example', '/\\evil.example', 'javascript:alert(1)', 'https://evil.example/x',
586
- '/api/gds/auth/web/callback', '/lib/bongos-client.js',
587
- '/task/1003305', // this same record — a loop, not a way back
588
- 'work', '/ work', '',
589
- ];
590
- for (const from of bad) {
591
- const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent(from) });
592
- await r.boot(); await settle();
593
- assert.match(r.html(), /href="\/work">Back to the work board</, `from=${JSON.stringify(from)}`);
594
- assert.equal(r.el('task-back').href, '/work', `the crumb refuses from=${JSON.stringify(from)} too`);
595
- }
569
+ test('the page no longer decides the way back: ?from= is the shell\'s to read', async () => {
570
+ const r = renderTask(BASE_TASK, { search: '?from=' + encodeURIComponent('/goals#/goal/1000081'), shell: true });
571
+ await r.boot(); await settle();
572
+ assert.doesNotMatch(r.html(), /href="\/goals#\/goal\/1000081"/, 'task.js writes no origin of its own into the exit');
573
+ assert.doesNotMatch(TASK_SRC, /\bsafeHallPath\b|\breferrerPath\b|\bexitTarget\b|\bapplyExit\b/,
574
+ 'the second copy of the origin logic is gone, so the two cannot drift');
596
575
  });
597
576
 
598
- test('the two honest failures exit the same way the record does', async () => {
599
- for (const status of [404, 500]) {
600
- const r = renderTask(BASE_TASK, { status, search: FROM_GOAL });
601
- await r.boot(); await settle();
602
- assert.match(r.html(), /href="\/goals#\/goal\/1000081">Back to goal 1000081</, `status ${status}`);
603
- assert.doesNotMatch(r.html(), /Open the work board/, `status ${status} still hardcodes the board`);
604
- }
577
+ test('without a shell the record still renders, and its exit still goes somewhere', async () => {
578
+ const r = renderTask(BASE_TASK);
579
+ await r.boot(); await settle();
580
+ assert.match(r.html(), EXIT_SLOT);
581
+ assert.equal(r.fills.length, 0);
605
582
  });
606
583
 
607
- test('the crumb is an element task.js can point, with its label apart from the icon', () => {
608
- assert.match(taskHtml, /<a class="rec-back" id="task-back" href="\/work">/, 'the static href is the fallback the page ships with');
609
- assert.match(taskHtml, /<span id="task-back-label">Back to tasks<\/span>/, 'the label is its own node, so the icon survives a relabel');
584
+ test('the crumb is the record\'s address only; the fallback rides the body', () => {
585
+ assert.doesNotMatch(taskHtml, /rec-back|task-back/, 'the page-drawn back link is gone');
586
+ assert.match(taskHtml, /<body [^>]*data-back-fallback="\/work"/, 'a deep-linked task still has a way back');
587
+ assert.match(taskHtml, /<p class="rec-sub" id="task-sub">/);
610
588
  });
611
589
 
612
590
  // ---- 11. the description is markdown, and renders as it (task 1003465) --------
@@ -92,3 +92,43 @@ test('the accessibility column is headed in plain language, not "Accessibility"'
92
92
  assert.match(thead, /Plain speech/, 'the column is headed "Plain speech"');
93
93
  assert.ok(!/>Accessibility</.test(thead), 'and does not collide with the zoom control\'s word');
94
94
  });
95
+
96
+ // ---- task 1002830: the transcript reader on the Sessions page (ADR 0051) ----
97
+ // Same technique as above: slice the shipped source (canRead/readDoor/turnHtml)
98
+ // and run it, so these exercise the real text.
99
+ function readerFns(viewer) {
100
+ const from = JS.indexOf(' function canRead(s)');
101
+ const to = JS.indexOf(' const reader = {');
102
+ assert.ok(from > 0 && to > from, 'the reader helpers are still where the slice expects them');
103
+ const src = JS.slice(from, to);
104
+ const escapeHtml = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
105
+ return new Function('escapeHtml', 'shortSid', 'fmtDate', 'V',
106
+ `let viewer = V;\n${src}\nreturn { canRead, readDoor, turnHtml };`)(escapeHtml, (s) => String(s).slice(0, 8), (d) => String(d), viewer);
107
+ }
108
+
109
+ test('a Read door appears only for a stored transcript the viewer may read', () => {
110
+ const owner = readerFns({ id: 7, rank: 'metic' });
111
+ assert.ok(owner.readDoor({ id: 1, session_id: 'abcdefgh1', builder_id: 7, has_transcript: true }).includes('data-read="1"'), 'own session');
112
+ assert.equal(owner.readDoor({ id: 2, session_id: 's', builder_id: 8, has_transcript: true }), '', "a Metic gets no door to someone else's transcript");
113
+ assert.equal(owner.readDoor({ id: 3, session_id: 's', builder_id: 7, has_transcript: false }), '', 'no stored transcript, no door');
114
+ const archon = readerFns({ id: 1, rank: 'archon' });
115
+ assert.ok(archon.readDoor({ id: 2, session_id: 's', builder_id: 8, has_transcript: true }).includes('data-read="2"'), 'an Archon may read any');
116
+ assert.equal(readerFns(null).readDoor({ id: 2, builder_id: 8, has_transcript: true }), '', 'no viewer, no door');
117
+ });
118
+
119
+ test('transcript text is escaped — stored content is arbitrary builder + tool output', () => {
120
+ const { turnHtml } = readerFns({ id: 1, rank: 'archon' });
121
+ const html = turnHtml({
122
+ role: 'assistant', text: '<img src=x onerror=alert(1)>',
123
+ tool_calls: [{ name: '<b>Bash</b>', input: '<script>x</script>' }],
124
+ tool_results: [{ text: '"><svg onload=1>', is_error: true }],
125
+ });
126
+ assert.ok(!/<img|<script|<svg|<b>/i.test(html), 'no raw markup survives');
127
+ assert.match(html, /&lt;img/);
128
+ assert.match(html, /turn__err/, 'an error result is marked');
129
+ });
130
+
131
+ test('the transcript panel lives on the SAME page, under the ledger (no second page)', () => {
132
+ assert.match(HTML, /id="transcript-scroll"/);
133
+ assert.match(JS, /\/sessions\/\$\{encodeURIComponent\(reader\.id\)\}\/transcript/);
134
+ });
@@ -635,12 +635,13 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-02-theme/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-02-back/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
642
642
  }
643
- assert.match(HTML, />← studio</);
643
+ // task 1004477: the bar's exit is the shell's, which fills its words.
644
+ assert.match(HTML, /<a class="tw-back" data-shell-exit href="\/builders\/studio">Back to studio</);
644
645
  assert.match(HTML, />submit page</);
645
646
  assert.match(HTML, />back to the studio</);
646
647
  });
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'a8dc2884930ca19a';
90
+ const SHELL_SHA = 'e5094c169d5ee6ef';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -264,6 +264,11 @@ await t('read pages in SQL and clamps offset/limit', async () => {
264
264
  await db.getSessionTranscript(5, {});
265
265
  assert.deepEqual(captured[0].params, [5, 0, 500]);
266
266
  });
267
+ await t('the ledger columns carry has_transcript from session_transcripts', async () => {
268
+ captured.length = 0;
269
+ await db.listSessionsByBuilder(7, 5);
270
+ assert.ok(captured[0].sql.replace(/\s+/g, ' ').includes('EXISTS (SELECT 1 FROM session_transcripts st WHERE st.session_id = sr.session_id) AS has_transcript'));
271
+ });
267
272
  pool.query = origQuery;
268
273
 
269
274
  Object.assign(auth, { requireBuilder: orig.requireBuilder, insertAuditLog: orig.insertAuditLog });