@bongos/core 1.21.182 → 1.22.1

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 (135) hide show
  1. package/.bongos-core.json +157 -137
  2. package/docs/copy-inventory.md +165 -165
  3. package/docs/copy-registry.json +184 -184
  4. package/docs/module-api-changelog.md +10 -0
  5. package/docs/page-inventory.json +35 -26
  6. package/docs/page-readings.json +389 -388
  7. package/modules/hall-ui/public/approval-queue.js +3 -3
  8. package/modules/hall-ui/public/atlas.html +2 -1
  9. package/modules/hall-ui/public/atlas.js +1 -1
  10. package/modules/hall-ui/public/blockers.html +3 -4
  11. package/modules/hall-ui/public/board-room.html +2 -2
  12. package/modules/hall-ui/public/brand-holes.js +3 -3
  13. package/modules/hall-ui/public/city.html +2 -1
  14. package/modules/hall-ui/public/collab.html +2 -2
  15. package/modules/hall-ui/public/commands.html +2 -2
  16. package/modules/hall-ui/public/commands.js +1 -2
  17. package/modules/hall-ui/public/copy-desk.html +2 -2
  18. package/modules/hall-ui/public/core-move-notice.js +1 -5
  19. package/modules/hall-ui/public/craft-mode.js +1 -5
  20. package/modules/hall-ui/public/criterion-uat-ui.js +3 -3
  21. package/modules/hall-ui/public/deploy.html +2 -2
  22. package/modules/hall-ui/public/diagrams.html +2 -1
  23. package/modules/hall-ui/public/dom-utils.js +15 -6
  24. package/modules/hall-ui/public/drachmae.html +2 -1
  25. package/modules/hall-ui/public/fleet-costs.js +3 -2
  26. package/modules/hall-ui/public/fleet.html +2 -2
  27. package/modules/hall-ui/public/founding-mark.js +1 -6
  28. package/modules/hall-ui/public/gate.html +2 -2
  29. package/modules/hall-ui/public/goal-map.html +2 -1
  30. package/modules/hall-ui/public/goals.html +2 -2
  31. package/modules/hall-ui/public/government.html +2 -2
  32. package/modules/hall-ui/public/hall-ask.js +3 -3
  33. package/modules/hall-ui/public/hall-kit.js +3 -5
  34. package/modules/hall-ui/public/hall-visual.js +3 -5
  35. package/modules/hall-ui/public/idea.html +2 -2
  36. package/modules/hall-ui/public/ideas.html +2 -2
  37. package/modules/hall-ui/public/index.html +2 -2
  38. package/modules/hall-ui/public/lightbulb.js +1 -5
  39. package/modules/hall-ui/public/md-reader.js +1 -8
  40. package/modules/hall-ui/public/modules.html +2 -2
  41. package/modules/hall-ui/public/my-agents.html +2 -2
  42. package/modules/hall-ui/public/my-agents.js +3 -1
  43. package/modules/hall-ui/public/page-notes.js +1 -5
  44. package/modules/hall-ui/public/paintbrush.js +1 -5
  45. package/modules/hall-ui/public/palette.js +1 -5
  46. package/modules/hall-ui/public/primer.html +2 -1
  47. package/modules/hall-ui/public/primer.js +1 -1
  48. package/modules/hall-ui/public/profile-crafts.js +3 -5
  49. package/modules/hall-ui/public/profile.html +2 -3
  50. package/modules/hall-ui/public/project-settings.html +2 -2
  51. package/modules/hall-ui/public/ranks.html +2 -3
  52. package/modules/hall-ui/public/roadmap.html +2 -2
  53. package/modules/hall-ui/public/roster.html +3 -4
  54. package/modules/hall-ui/public/sessions.html +2 -3
  55. package/modules/hall-ui/public/settings.html +2 -3
  56. package/modules/hall-ui/public/shell.js +1 -5
  57. package/modules/hall-ui/public/sky-data.js +1 -1
  58. package/modules/hall-ui/public/street.html +2 -1
  59. package/modules/hall-ui/public/studio.html +2 -2
  60. package/modules/hall-ui/public/task-detail.js +1 -6
  61. package/modules/hall-ui/public/task-lib.js +3 -8
  62. package/modules/hall-ui/public/task.html +2 -3
  63. package/modules/hall-ui/public/thinking.html +2 -1
  64. package/modules/hall-ui/public/tweak-editor.html +2 -2
  65. package/modules/hall-ui/public/vote-control.js +1 -1
  66. package/modules/hall-ui/public/watch.html +2 -3
  67. package/modules/hall-ui/public/welcome-boot.js +3 -5
  68. package/modules/hall-ui/public/work.html +2 -3
  69. package/modules/lifecycle/ship-card.js +1 -4
  70. package/modules/npm-release/preview/proxy.js +2 -3
  71. package/modules/npm-release/public/task-widget.js +1 -1
  72. package/modules/platform-identity/routes/sso.js +1 -3
  73. package/modules/provisioning/routes/callback-page.js +2 -6
  74. package/modules/public-landing/public/projects.html +3 -5
  75. package/modules/raise/raise.js +1 -1
  76. package/package-lock.json +2 -2
  77. package/package.json +1 -1
  78. package/release-notes.json +20 -12
  79. package/scripts/bongos/escape-helper-guard.js +147 -0
  80. package/scripts/bongos/fitness.js +3 -2
  81. package/scripts/bongos/key-card.js +1 -4
  82. package/scripts/bongos/review-sheet.js +1 -3
  83. package/scripts/bongos/start.js +1 -1
  84. package/scripts/bongos/status.js +1 -1
  85. package/scripts/gds/escape-helper-guard.js +6 -0
  86. package/scripts/status-mirror-sync.js +2 -3
  87. package/src/bongos/module-scope-map.js +1 -0
  88. package/src/bongos/platform-visibility-gate.js +2 -3
  89. package/src/bongos/prelaunch-gate.js +2 -3
  90. package/src/bongos/routes/auth.js +1 -5
  91. package/src/bongos/serve-internal.js +2 -6
  92. package/src/html-escape.js +26 -0
  93. package/src/module-api.js +9 -1
  94. package/tests/blockers_kit_pager.mjs +2 -2
  95. package/tests/blockers_page_ui.mjs +2 -2
  96. package/tests/core_move_notice.mjs +3 -1
  97. package/tests/core_update_banner.mjs +2 -0
  98. package/tests/core_upgrade_owner_door.mjs +2 -0
  99. package/tests/craft_mode.mjs +2 -0
  100. package/tests/craft_mode_nav.mjs +3 -0
  101. package/tests/craft_review_mode.mjs +3 -2
  102. package/tests/criterion_ref_status.mjs +7 -2
  103. package/tests/curve_kind_overlay.mjs +2 -1
  104. package/tests/founding_mode.mjs +10 -2
  105. package/tests/github_install_nudge.mjs +4 -1
  106. package/tests/goal_map_page.mjs +2 -2
  107. package/tests/hall_approval_queue.mjs +2 -2
  108. package/tests/hall_city.mjs +1 -1
  109. package/tests/hall_fullscreen.mjs +2 -0
  110. package/tests/hall_gov_real_site.mjs +2 -0
  111. package/tests/hall_kit_charts.mjs +2 -1
  112. package/tests/hall_my_agents.mjs +3 -2
  113. package/tests/hall_nav.mjs +2 -0
  114. package/tests/hall_palette.mjs +2 -2
  115. package/tests/hall_reading_world.mjs +3 -1
  116. package/tests/hall_reference_world.mjs +2 -1
  117. package/tests/hall_signout.mjs +2 -0
  118. package/tests/hall_street.mjs +1 -1
  119. package/tests/hall_studio_world.mjs +3 -1
  120. package/tests/hall_tweak_editor.mjs +2 -1
  121. package/tests/helpers.mjs +17 -2
  122. package/tests/html_escape.mjs +149 -0
  123. package/tests/lightbulb.mjs +2 -0
  124. package/tests/module_api.mjs +1 -0
  125. package/tests/module_store_howto.mjs +2 -1
  126. package/tests/nav_permission_atoms.mjs +1 -1
  127. package/tests/npm_release_where.mjs +2 -1
  128. package/tests/page_notes_panel.mjs +4 -3
  129. package/tests/paintbrush.mjs +2 -1
  130. package/tests/profile_connections_ui.mjs +2 -1
  131. package/tests/profile_role_headline.mjs +2 -2
  132. package/tests/profile_role_tabs.mjs +2 -2
  133. package/tests/projects_hub_repo_private.mjs +4 -0
  134. package/tests/terms_acceptance.mjs +1 -0
  135. package/tests/welcome_boot_browser.mjs +1 -1
@@ -0,0 +1,147 @@
1
+ // scripts/bongos/escape-helper-guard.js — fitness check: ONE HTML escaper per side
2
+ // (task 1004876, goal 1000135).
3
+ //
4
+ // WHY. The HTML escaper is what keeps a builder's own text (a task title, an idea,
5
+ // a goal name) from turning into live markup on someone else's screen. The
6
+ // 2026-10-25 audit found ~45 private copies of it, and six of them escaped fewer
7
+ // characters than the rest (no single quote) or printed "null" for a missing
8
+ // value — each one a place a cross-site-scripting hole could hide. They were
9
+ // collapsed onto exactly two definitions:
10
+ //
11
+ // browser modules/hall-ui/public/dom-utils.js → window.OTB.escapeHtml
12
+ // server src/html-escape.js → escapeHtml (modules reach it
13
+ // through src/module-api.js)
14
+ //
15
+ // This check keeps it that way. It does not look for a NAME (a copy called
16
+ // `hesc` or `sesc` hid from a name search) — it looks for the escape TABLE
17
+ // itself: a quoted < string literal is what every hand-rolled escaper has
18
+ // to contain, and nothing else in the code needs one. A file that contains one
19
+ // outside the allow-list below is a new private escaper: delete it and call the
20
+ // shared one (a thin `function esc(s) { return window.OTB.escapeHtml(s); }`
21
+ // delegate is fine — it holds no table).
22
+ //
23
+ // A delegate must really delegate, though. primer.js used to fall back to
24
+ // `(s) => String(s)` — an "escaper" with no table that escaped nothing — so a
25
+ // local HTML-escaper NAME (esc / escapeHtml / escAttr / escHtml / hesc / sesc)
26
+ // must point at a shared escaper on the line that defines it. In browser code
27
+ // (modules/*/public/) every such name is checked; on the server `esc` alone is
28
+ // exempt, because scripts there use it for regex and markdown escaping too.
29
+ //
30
+ // Second half: a hall page must load dom-utils.js as its FIRST hall script and
31
+ // without `defer`/`async`, because shell.js, palette.js and craft-mode.js run
32
+ // while the page parses and now escape through window.OTB.
33
+
34
+ 'use strict';
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ const { REPO_ROOT } = require('./fitness-lib.js');
40
+
41
+ // The two shared escapers, plus the one file whose escaper is NOT an HTML one.
42
+ const ALLOWED = new Map([
43
+ ['modules/hall-ui/public/dom-utils.js', 'the shared BROWSER escaper (window.OTB.escapeHtml)'],
44
+ ['src/html-escape.js', 'the shared SERVER escaper (module-api escapeHtml)'],
45
+ ['modules/copy-desk/docx.js', 'an XML escaper for .docx files (', drops control characters) — a different contract'],
46
+ ]);
47
+
48
+ // Where code lives. Tests, docs and vendored bundles are out of scope: a test may
49
+ // carry its own decoder/encoder to CHECK the shared one against.
50
+ const SCAN_ROOTS = ['src', 'scripts', 'modules', 'server.js', 'bin'];
51
+ const SCAN_EXT = /\.(c|m)?js$|\.html$/;
52
+ const SKIP_DIR = new Set(['node_modules', '.git', 'tests', 'fixtures']);
53
+
54
+ // A quoted < literal — the entity standing alone as a string in any quote style.
55
+ const ESCAPE_TABLE_RE = /(['"`])<\1/;
56
+
57
+ function walk(abs, out) {
58
+ let st;
59
+ try { st = fs.statSync(abs); } catch { return out; }
60
+ if (st.isFile()) { if (SCAN_EXT.test(abs)) out.push(abs); return out; }
61
+ for (const ent of fs.readdirSync(abs, { withFileTypes: true })) {
62
+ if (SKIP_DIR.has(ent.name)) continue;
63
+ walk(path.join(abs, ent.name), out);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ // findEscapeTables(text) → 1-based line numbers holding a hand-rolled escape table.
69
+ function findEscapeTables(text) {
70
+ const lines = String(text).split('\n');
71
+ const hits = [];
72
+ for (let i = 0; i < lines.length; i++) if (ESCAPE_TABLE_RE.test(lines[i])) hits.push(i + 1);
73
+ return hits;
74
+ }
75
+
76
+ // findFakeDelegates(text, { browser }) → 1-based lines that define a local HTML
77
+ // escaper name WITHOUT pointing it at a shared escaper (window.OTB.escapeHtml, a
78
+ // kit/reader alias of it, api.escapeHtml, or src/html-escape.js).
79
+ const BROWSER_NAMES = /(?:function\s+|(?:const|let|var)\s+)(esc|escapeHtml|escAttr|escHtml|hesc|sesc)\s*(?:\(|=)/;
80
+ const SERVER_NAMES = /(?:function\s+|(?:const|let|var)\s+)(escapeHtml|escAttr|escHtml|hesc|sesc)\s*(?:\(|=)/;
81
+ const POINTS_AT_SHARED = /\.escapeHtml\b|\.esc\b|\bescapeHtml;|require\([^)]*html-escape/;
82
+ function findFakeDelegates(text, { browser = false } = {}) {
83
+ const lines = String(text).split('\n');
84
+ const hits = [];
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const m = lines[i].match(browser ? BROWSER_NAMES : SERVER_NAMES);
87
+ if (!m) continue;
88
+ if (!POINTS_AT_SHARED.test(lines[i].slice(m.index + m[0].length))) hits.push(i + 1);
89
+ }
90
+ return hits;
91
+ }
92
+
93
+ // hallPageOrderProblem(html) → null when the page is fine, else a sentence saying
94
+ // why. A page loading no /builders/ script at all has nothing to order.
95
+ function hallPageOrderProblem(html) {
96
+ const tags = [...String(html).matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/g)]
97
+ .map((m) => ({ tag: m[0], src: m[1].split('?')[0] }))
98
+ .filter((t) => t.src.startsWith('/builders/'));
99
+ if (!tags.length) return null;
100
+ const at = tags.findIndex((t) => t.src === '/builders/dom-utils.js');
101
+ if (at < 0) return 'loads hall scripts but never /builders/dom-utils.js (window.OTB.escapeHtml would be undefined)';
102
+ if (at > 0) return `loads ${tags[0].src} before /builders/dom-utils.js — dom-utils must be the first hall script`;
103
+ if (/\b(defer|async)\b/.test(tags[at].tag)) return 'loads /builders/dom-utils.js with defer/async — scripts that run while the page parses would find no window.OTB';
104
+ if (tags.filter((t) => t.src === '/builders/dom-utils.js').length > 1) return 'loads /builders/dom-utils.js twice';
105
+ return null;
106
+ }
107
+
108
+ function checkOneHtmlEscaper({ root = REPO_ROOT } = {}) {
109
+ const violations = [];
110
+ let scanned = 0;
111
+ for (const r of SCAN_ROOTS) {
112
+ for (const abs of walk(path.join(root, r), [])) {
113
+ scanned++;
114
+ const relPath = path.relative(root, abs).split(path.sep).join('/');
115
+ if (ALLOWED.has(relPath)) continue;
116
+ const text = fs.readFileSync(abs, 'utf8');
117
+ const hits = findEscapeTables(text);
118
+ if (hits.length) {
119
+ violations.push(`${relPath}:${hits[0]} — a private HTML escaper (a quoted &lt; literal: an escape table). Use the shared one: `
120
+ + 'window.OTB.escapeHtml in the browser, escapeHtml from src/html-escape.js (or api.escapeHtml in a module) on the server.');
121
+ }
122
+ const fakes = findFakeDelegates(text, { browser: /^modules\/[^/]+\/public\//.test(relPath) });
123
+ if (fakes.length) {
124
+ violations.push(`${relPath}:${fakes[0]} — defines a local HTML escaper name that does not point at a shared escaper `
125
+ + '(a fallback like `(s) => String(s)` escapes nothing). Delegate to window.OTB.escapeHtml / api.escapeHtml / src/html-escape.js.');
126
+ }
127
+ }
128
+ }
129
+ const hallDir = path.join(root, 'modules', 'hall-ui', 'public');
130
+ let pages = 0;
131
+ for (const abs of walk(hallDir, []).filter((f) => f.endsWith('.html'))) {
132
+ pages++;
133
+ const problem = hallPageOrderProblem(fs.readFileSync(abs, 'utf8'));
134
+ if (problem) violations.push(`${path.relative(root, abs).split(path.sep).join('/')} — ${problem}`);
135
+ }
136
+ return {
137
+ name: 'one HTML escaper per side (task 1004876)',
138
+ ok: violations.length === 0,
139
+ hardFail: violations.length > 0,
140
+ violations,
141
+ warnings: [],
142
+ note: `${scanned} code files hold no private escape table (allowed: ${[...ALLOWED.keys()].join(', ')}); `
143
+ + `${pages} hall pages load dom-utils.js first.`,
144
+ };
145
+ }
146
+
147
+ module.exports = { checkOneHtmlEscaper, findEscapeTables, findFakeDelegates, hallPageOrderProblem, ALLOWED };
@@ -523,7 +523,7 @@ const KERNEL_FILES = [
523
523
  'src/module-api.js', // the published doorway
524
524
  'src/modules.js', // the registry/resolver facade
525
525
  'src/module-seams.js', // the seam registry (ports/events/contributions)
526
- 'src/role-responsibilities.js', // the owner's written role statements (task 1003732) — modules.js reads it; imports nothing
526
+ 'src/role-responsibilities.js', 'src/html-escape.js', // owner's role statements (task 1003732) + THE server HTML escaper (task 1004876) — both import nothing
527
527
  'src/module-loader/loader.js', // the disk loader (discover → validate → mount)
528
528
  'src/module-loader/manifest-schema.js', // the manifest validator
529
529
  'src/module-loader/semver.js', // coreVersion compatibility
@@ -1371,6 +1371,7 @@ const CHECKS = [
1371
1371
  // Task 1003206 carved this file from 2,004 lines to ~1,350, so that toll is paid
1372
1372
  // and the check gets its own line back.
1373
1373
  require('./version-literal-guard.js').checkNoVersionLiterals,
1374
+ require('./escape-helper-guard.js').checkOneHtmlEscaper, // task 1004876: ONE HTML escaper per side (dom-utils.js / src/html-escape.js), and hall pages load dom-utils first
1374
1375
  require('./dev-box-guard.js').checkNoDevBox, // task 1003899 / ADR 0346: the retired dev box stays out (its exemption list lives in that file)
1375
1376
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1376
1377
  require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
@@ -1436,7 +1437,7 @@ module.exports = {
1436
1437
  scanForRawNul,
1437
1438
  checkVocabularyWall, VOCAB_ALLOW, VOCAB_CITATION, VOCAB_EXTS, vocabFilesToScan,
1438
1439
  checkGoalMembershipVocabulary, GOAL_VOCAB_SURFACE, checkGovernmentRenameIdentity, RENAME_IDENTITY_RE, checkPrelaunchVocabulary, PRELAUNCH_SURFACE, // 19c task 1003120, 19d task 1003152
1439
- runAll,
1440
+ runAll, CHECKS, // CHECKS: so a test can prove a check is registered, not just written (task 1004876)
1440
1441
  resolvesToHostGame,
1441
1442
  checkCoreHostBoundary,
1442
1443
  checkClaudeMdBudget,
@@ -112,10 +112,7 @@ function renderMonospaceKeyCard(model) {
112
112
  }
113
113
 
114
114
  // ---- HTML renderer (the in-feed branded card) ---------------------------
115
- function esc(s) {
116
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
117
- ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
118
- }
115
+ const { escapeHtml: esc } = require('../../src/html-escape'); // the one server escaper (task 1004876)
119
116
  function renderKeyCardHtml(model) {
120
117
  const m = model;
121
118
  const bg = 'var(--color-background-warning)';
@@ -24,9 +24,7 @@ const path = require('node:path');
24
24
  const { pathToFileURL } = require('node:url');
25
25
  const { spawn } = require('node:child_process');
26
26
 
27
- function escapeHtml(s) {
28
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
29
- }
27
+ const { escapeHtml } = require('../../src/html-escape'); // the one server escaper (task 1004876)
30
28
 
31
29
  // parseArgs — the flags above; `shots` keep their order. A --shot with no "="
32
30
  // is an error, not a guess: the caption and the path cannot be told apart.
@@ -268,7 +268,7 @@ function claimableTable(rows, rewardFromServer, goals) {
268
268
  // the same card every time, app-native. Falls back to the markdown card when
269
269
  // the client can't draw widgets. Design system: flat, 0.5px borders, CSS
270
270
  // theme vars (light/dark), Tabler icons, no emoji.
271
- const hesc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
271
+ const { escapeHtml: hesc } = require('../../src/html-escape'); // the one server escaper (task 1004876); it also escapes '
272
272
  const jsq = (s) => String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, ' ');
273
273
 
274
274
  function miniBar(pct) {
@@ -223,7 +223,7 @@ function clabel(c) {
223
223
  // builder-start card (flat, 0.5px borders, CSS theme vars, Tabler icons, no
224
224
  // emoji); classes are `.st-*` so they can't collide with builder-start's `.bs-*`
225
225
  // or the ship card's `.cc-*`.
226
- const sesc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
226
+ const { escapeHtml: sesc } = require('../../src/html-escape'); // the one server escaper (task 1004876); it also escapes '
227
227
 
228
228
  // A value going inside a single-quoted JS string inside an HTML attribute needs
229
229
  // BOTH escapes, in that order. Task ids are digits so the existing handlers get
@@ -0,0 +1,6 @@
1
+ // scripts/gds/escape-helper-guard.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/escape-helper-guard.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'escape-helper-guard.js');
@@ -49,6 +49,7 @@ const { execFileSync } = require('node:child_process');
49
49
  const REPO_ROOT = path.resolve(__dirname, '..');
50
50
  const SRC = path.join(REPO_ROOT, 'modules', 'status-ui', 'public');
51
51
  const { branding, clientBranding, applyBrandConditionals } = require('../src/branding');
52
+ const { escapeHtml } = require('../src/html-escape');
52
53
 
53
54
  // Stamp instance-identity tokens into the mirror HTML. The off-box mirror is
54
55
  // served by a static Pages host, NOT the branding-injecting Node server — so this
@@ -64,9 +65,7 @@ function brandTokens(html, brand = {}, publicOrigin = '', buildersOrigin = '') {
64
65
  const cur = (brand.currency && brand.currency.label) || 'credits';
65
66
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
66
67
  const company = (brand.identity && brand.identity.company) || {};
67
- const esc = (s) => String(s == null ? '' : s)
68
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
69
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
68
+ const esc = escapeHtml; // the one server escaper (task 1004876)
70
69
  return applyBrandConditionals(html, { companyName: company.name, companySite: company.site, companyTagline: company.tagline })
71
70
  .split('{{worldName}}').join(esc(world))
72
71
  .split('{{productName}}').join(esc(product))
@@ -464,6 +464,7 @@ const MODULE_GLOBS = {
464
464
  'scripts/bongos/route-shadow-guard.js', 'scripts/bongos/routine-schedule.js',
465
465
  'scripts/bongos/seam-wiring-guard.js', 'scripts/bongos/skill-preflight.js',
466
466
  'scripts/bongos/test-path-guard.js', 'scripts/bongos/version-literal-guard.js', 'scripts/bongos/dev-box-guard.js',
467
+ 'scripts/bongos/escape-helper-guard.js', // task 1004876
467
468
 
468
469
  // ADOPTED (task 1004111): the enforcement surfaces this module already IS.
469
470
  // install-git-hooks.js + scripts/bongos/hooks/ are the pre-push hook beside the
@@ -177,9 +177,8 @@ function relativeReturn(raw) {
177
177
  return raw === '/' ? null : raw;
178
178
  }
179
179
 
180
- function escAttr(s) {
181
- return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
182
- }
180
+ // The one server escaper (task 1004876); it also escapes ' , which this copy did not.
181
+ const { escapeHtml: escAttr } = require('../html-escape');
183
182
 
184
183
  // The door. Self-contained on purpose (the pre-launch gate's shape): it is
185
184
  // served to someone who may not see the project, so it pulls no stylesheet, no
@@ -180,9 +180,8 @@ function relativeReturn(raw) {
180
180
  return raw;
181
181
  }
182
182
 
183
- function escAttr(s) {
184
- return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
185
- }
183
+ // The one server escaper (task 1004876); it also escapes ' , which this copy did not.
184
+ const { escapeHtml: escAttr } = require('../html-escape');
186
185
 
187
186
  // The form. Self-contained and token-free on purpose: it is served BEFORE the
188
187
  // visitor is allowed to see anything, so it must not pull the branding bundle,
@@ -44,11 +44,7 @@ const seams = require('../../module-seams');
44
44
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
45
45
  const log = require('../logger').logger.child({ src: 'auth' });
46
46
 
47
- function escapeHtml(s) {
48
- return String(s ?? '').replace(/[&<>"']/g, (c) => ({
49
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
50
- }[c]));
51
- }
47
+ const { escapeHtml } = require('../../html-escape'); // the one server escaper (task 1004876)
52
48
 
53
49
  // Render a small standalone HTML page in the builders' hall palette. Used for
54
50
  // OAuth error branches so the visitor sees something coherent rather than a
@@ -264,12 +264,8 @@ function dropEmptyHrefLinks(html, values) {
264
264
  // No token is ever emitted into a <script> or a CSS context (checked across
265
265
  // public-landing/, hall-ui/, status-ui/ — all under modules/ since task 1003322)
266
266
  // — that would need different escaping.
267
- // Ampersand first, or the other replacements get double-escaped.
268
- function esc(s) {
269
- return String(s == null ? '' : s)
270
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
271
- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
272
- }
267
+ // The one server escaper (src/html-escape.js, task 1004876).
268
+ const { escapeHtml: esc } = require('../html-escape');
273
269
  // The instance apex origin from the full branding pack (domains is server-only,
274
270
  // so it never rides clientBranding). Best-effort so a branding hiccup can't crash
275
271
  // serving.
@@ -0,0 +1,26 @@
1
+ // src/html-escape.js — THE server-side HTML escaper (task 1004876, goal 1000135).
2
+ //
3
+ // Escapes the five characters that let text become markup — & < > " ' — so a
4
+ // builder's own words (a task title, an error detail, a project name) stay text
5
+ // when they are written into a page, an attribute or an email. null and
6
+ // undefined render as empty text; anything else is String()-ed first.
7
+ //
8
+ // ONE copy per side, on purpose. The browser twin is window.OTB.escapeHtml in
9
+ // modules/hall-ui/public/dom-utils.js and the two must agree on every input
10
+ // (tests/html_escape.mjs runs both). Modules reach this one through the doorway
11
+ // (`api.escapeHtml`, src/module-api.js — ADR 0083); core files and scripts
12
+ // require it directly. scripts/bongos/escape-helper-guard.js fails fitness on a
13
+ // third hand-rolled copy anywhere in the code.
14
+ //
15
+ // Zero dependencies by design: the CLI requires it, so it must cost nothing to load.
16
+
17
+ 'use strict';
18
+
19
+ const TABLE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
20
+
21
+ /** HTML-escape `s` for text or a quoted attribute value. null/undefined → ''. */
22
+ function escapeHtml(s) {
23
+ return String(s ?? '').replace(/[&<>"']/g, (c) => TABLE[c]);
24
+ }
25
+
26
+ module.exports = { escapeHtml };
package/src/module-api.js CHANGED
@@ -57,6 +57,10 @@ const { buildInfo } = require('./build-info');
57
57
  // (`responsibilitiesFor` via src/modules.js, `ROLE_RESPONSIBILITIES` via
58
58
  // scripts/bongos/gen-role-responsibilities.js) were never flagged and this one was.
59
59
  const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibilities');
60
+ // Eager and destructured for the same reasons: html-escape.js requires nothing.
61
+ // escapeHtml is THE server-side HTML escaper (task 1004876) — a module renders
62
+ // user text into a page through this, never through a private copy.
63
+ const { escapeHtml } = require('./html-escape');
60
64
 
61
65
  // ---------------------------------------------------------------------------
62
66
  // CORE_VERSION — the version of THIS published surface (semver).
@@ -75,7 +79,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
79
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
80
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
81
 
78
- const CORE_VERSION = '1.21.182'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
82
+ const CORE_VERSION = '1.22.1'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
83
 
80
84
  // A namespaced logger so a module's log lines are attributable + consistent.
81
85
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -122,6 +126,10 @@ function logger(moduleKey) {
122
126
  module.exports = {
123
127
  CORE_VERSION,
124
128
 
129
+ // escapeHtml(s) — the one server-side HTML escaper (& < > " '; null → ''). The
130
+ // browser twin is window.OTB.escapeHtml; fitness fails on a third copy (task 1004876).
131
+ escapeHtml,
132
+
125
133
  // --- auth & rank (ADR 0016) — the gates the loader composes around a module's
126
134
  // routes; a module declares the rank it needs and never wires auth itself.
127
135
  get requireBuilder() { return require('./bongos/auth').requireBuilder; },
@@ -22,7 +22,7 @@ import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import vm from 'node:vm';
24
24
  import { fileURLToPath } from 'node:url';
25
- import { makeRunner } from './helpers.mjs';
25
+ import { makeRunner, realOTB } from './helpers.mjs';
26
26
 
27
27
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
28
  const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
@@ -97,7 +97,7 @@ function harness({ total = 57, failAfter = null, perPagePref = null } = {}) {
97
97
  getItem: (k) => (store.has(k) ? store.get(k) : null),
98
98
  setItem: (k, v) => store.set(k, String(v)),
99
99
  },
100
- OTB: { toast: (msg) => toasts.push(msg) },
100
+ OTB: { ...realOTB(), toast: (msg) => toasts.push(msg) },
101
101
  addEventListener() {},
102
102
  // loadViewer() calls this for GET /me; signed-out (401) keeps isReviewer()
103
103
  // false, which is fine for these paging tests — they don't inspect the
@@ -26,7 +26,7 @@ import path from 'node:path';
26
26
  import vm from 'node:vm';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { createRequire } from 'node:module';
29
- import { makeRunner, makeEl, walk } from './helpers.mjs';
29
+ import { makeRunner, makeEl, walk, realOTB } from './helpers.mjs';
30
30
 
31
31
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
32
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -114,7 +114,7 @@ async function boot(opts = {}) {
114
114
  getElementById(id) { return walk(root, (el) => (el.id === id ? el : null)); },
115
115
  };
116
116
  const windowObj = {
117
- OTB: { toast: (m) => toasts.push(m) },
117
+ OTB: { ...realOTB(), toast: (m) => toasts.push(m) },
118
118
  OTBBlockersLib,
119
119
  OTBTaskDetail: { href: (id) => '/task/' + id },
120
120
  BongosClient: { createClient: () => api },
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import vm from 'node:vm';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { createRequire } from 'node:module';
18
+ import { realOTB } from './helpers.mjs';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -233,6 +234,7 @@ function boot({ data, permissions = { 'core.pin.move': true }, stored = {} } = {
233
234
  };
234
235
  const location = { hostname: 'h', origin: 'https://h', pathname: '/', hash: '', assign() {}, replace() {} };
235
236
  const windowObj = {
237
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
236
238
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning: false } },
237
239
  BongosClient: { createClient: () => ({ request: (method, p, opts) => {
238
240
  requested.push({ method, p, body: opts && opts.body });
@@ -313,7 +315,7 @@ async function panel(data) {
313
315
  const el = () => ({ hidden: true, textContent: '', _html: '', dataset: {}, set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; }, querySelectorAll: () => [] });
314
316
  const els = new Map([['software-update-scroll', el()], ['su-body', el()], ['su-note', el()], ['settings-groups', el()]]);
315
317
  els.get('settings-groups').dataset.active = 'update';
316
- const windowObj = { BongosClient: { createClient: () => ({ request: async () => ({ ok: true, status: 200, data }) }) }, OTB: { escapeHtml }, location: { assign() {} } };
318
+ const windowObj = { BongosClient: { createClient: () => ({ request: async () => ({ ok: true, status: 200, data }) }) }, OTB: { ...realOTB(), escapeHtml }, location: { assign() {} } };
317
319
  const sandbox = { window: windowObj, document: { getElementById: (id) => els.get(id) || null, addEventListener() {} }, console, setTimeout, Date, Math, URL, Promise, String, Number };
318
320
  vm.createContext(sandbox);
319
321
  vm.runInContext(PANEL_SRC, sandbox, { filename: 'settings-software-update.js' });
@@ -12,6 +12,7 @@ import path from 'node:path';
12
12
  import vm from 'node:vm';
13
13
  import { createRequire } from 'node:module';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { realOTB } from './helpers.mjs';
15
16
 
16
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const require_ = createRequire(import.meta.url);
@@ -265,6 +266,7 @@ function boot({ permissions, update, status = 200, dismissed = null, provisionin
265
266
  };
266
267
  const location = { hostname: 'h', origin: 'https://h', pathname, hash: '', assign() {}, replace() {} };
267
268
  const windowObj = {
269
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
268
270
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
269
271
  BongosClient: {
270
272
  createClient: () => ({
@@ -23,6 +23,7 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import vm from 'node:vm';
25
25
  import { fileURLToPath } from 'node:url';
26
+ import { realOTB } from './helpers.mjs';
26
27
 
27
28
  const require = createRequire(import.meta.url);
28
29
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -409,6 +410,7 @@ function boot({ provisioning, deployUrl, pathname = '/settings', hash = '', held
409
410
  const location = { hostname: 'h', origin: 'https://h', pathname, hash, assign() {}, replace() {} };
410
411
  const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
411
412
  const windowObj = {
413
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
412
414
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
413
415
  BongosClient: {
414
416
  createClient: () => ({
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import vm from 'node:vm';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { realOTB } from './helpers.mjs';
17
18
 
18
19
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -115,6 +116,7 @@ function boot({ craft = true, session = {}, store = {}, throws = false, optOut =
115
116
  removeItem: (k) => { if (throws) throw new Error('SecurityError'); delete obj[k]; },
116
117
  });
117
118
  const windowObj = {
119
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
118
120
  __BRANDING__: {}, __MODULES__: { enabled: {} },
119
121
  BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
120
122
  matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q), addEventListener() {} }),
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import vm from 'node:vm';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { realOTB } from './helpers.mjs';
17
18
 
18
19
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -90,6 +91,7 @@ function boot({ session = {}, perms = { 'board.vote.cast': true } } = {}) {
90
91
  const location = { hostname: 'h', origin: 'https://h', href: 'https://h/work', pathname: '/work', hash: '', search: '', assign() {}, replace() {}, reload() {} };
91
92
  const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
92
93
  const windowObj = {
94
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
93
95
  __BRANDING__: {}, __MODULES__: { enabled: { provisioning: true } },
94
96
  BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
95
97
  matchMedia: () => ({ matches: false, addEventListener() {} }),
@@ -274,6 +276,7 @@ function bootTree({ mode = null, activeId = null } = {}) {
274
276
  addEventListener() {}, removeEventListener() {},
275
277
  };
276
278
  const windowObj = {
279
+ OTB: realOTB(), // the page loads dom-utils.js first (task 1004876)
277
280
  location: { search: '' }, matchMedia: () => ({ matches: false }),
278
281
  localStorage: mk({}), sessionStorage: mk(mode ? { 'hall-craft': mode } : {}),
279
282
  };
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import vm from 'node:vm';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { realOTB } from './helpers.mjs';
16
17
 
17
18
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
19
  const HALL = path.join(ROOT, 'modules/hall-ui/public');
@@ -77,7 +78,7 @@ function boot({ session = {}, store = {}, narrow = false } = {}) {
77
78
  dispatchEvent() { return true; },
78
79
  };
79
80
  const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
80
- const window = { localStorage: mk(store), sessionStorage: mk(session), matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q) }), location: { search: '' } };
81
+ const window = { OTB: realOTB(), localStorage: mk(store), sessionStorage: mk(session), matchMedia: (q) => ({ matches: narrow && /max-width: 640px/.test(q) }), location: { search: '' } };
81
82
  const sandbox = { window, document, Promise, setTimeout, clearTimeout, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
82
83
  vm.createContext(sandbox);
83
84
  // "Loading" a script: the wall runs the REAL craft-review.js; the palette is a fake panel.
@@ -208,7 +209,7 @@ test('THE WALL is scribbled in CSS only, in ink alone, and stands still under re
208
209
  });
209
210
 
210
211
  test('craft-review.js draws six small sky doodles, each with its pen, the wide ones foldable', () => {
211
- const sandbox = { window: {} };
212
+ const sandbox = { window: { OTB: realOTB() } };
212
213
  vm.createContext(sandbox);
213
214
  vm.runInContext(REVIEW, sandbox);
214
215
  const api = sandbox.window.OTBCraftReview;
@@ -194,8 +194,13 @@ test('a slug carrying an apostrophe cannot break the click handler', () => {
194
194
  // single-quoted JS string inside an HTML attribute.
195
195
  const odd = { ...crit(2, "o'hara-rule", '1000999') };
196
196
  const html = status.renderStatusWidget({ ...DATA, criteria: [odd] }, null);
197
- assert.ok(html.includes("sendPrompt('/status o\\'hara-rule')"),
198
- 'the apostrophe was not escaped for the JS string');
197
+ // The attribute is HTML-escaped AFTER the JS escape, so the apostrophe arrives as
198
+ // \&#39; (task 1004876: the shared escaper escapes ' too) — the browser decodes the
199
+ // attribute first, and the JS it then parses is the \' asserted below.
200
+ const onclick = html.match(/onclick="([^"]*o\\&#39;hara-rule[^"]*)"/);
201
+ assert.ok(onclick, 'the apostrophe was not escaped for the JS string');
202
+ assert.ok(onclick[1].replace(/&#39;/g, "'").includes("sendPrompt('/status o\\'hara-rule')"),
203
+ 'the decoded handler carries the JS-escaped apostrophe');
199
204
  });
200
205
 
201
206
  console.log(`\ncriterion ref — read side (task 1004069): ${passed} passed, ${failed} failed`);
@@ -26,12 +26,13 @@ import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import vm from 'node:vm';
28
28
  import { fileURLToPath } from 'node:url';
29
+ import { realOTB } from './helpers.mjs';
29
30
 
30
31
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
32
 
32
33
  function loadKit() {
33
34
  const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/hall-kit.js'), 'utf8');
34
- const win = {};
35
+ const win = { OTB: realOTB() }; // the page loads dom-utils.js first (task 1004876)
35
36
  const ctx = vm.createContext({ window: win, document: { createElement: () => ({}) }, console });
36
37
  vm.runInContext(src, ctx);
37
38
  assert.ok(win.OTBKit, 'hall-kit.js did not publish window.OTBKit');
@@ -13,6 +13,7 @@ import { createRequire } from 'node:module';
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { realOTB } from './helpers.mjs';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
19
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -135,6 +136,13 @@ function makeHall() {
135
136
 
136
137
  // The REAL escaper and the real wake map are sliced out of shell.js with the function,
137
138
  // so the band's escaping is the shell's own, not a stand-in that agrees with the test.
139
+ // shell.js escapes through window.OTB (dom-utils.js, task 1004876), so the window it is
140
+ // handed carries the real one.
141
+ function sliceEsc() {
142
+ const line = SHELL.split("\n").find((l) => l.startsWith(" function esc("));
143
+ assert.ok(line, "esc was located in shell.js");
144
+ return line;
145
+ }
138
146
  function sliceFn(name) {
139
147
  const start = SHELL.indexOf(' function ' + name + '(');
140
148
  const end = SHELL.indexOf('\n }\n', start);
@@ -145,8 +153,8 @@ function loadApplyFounding(document, access, brand = { identity: { productName:
145
153
  const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
146
154
  const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
147
155
  assert.ok(wakeStart > -1, 'WAKE_TEXT was located in shell.js');
148
- const src = [SHELL.slice(wakeStart, wakeEnd), sliceFn('esc'), sliceFn('applyFounding')].join('\n');
149
- return new Function('document', 'brand', 'access', `${src}\nreturn applyFounding;`)(document, brand, access);
156
+ const src = [SHELL.slice(wakeStart, wakeEnd), sliceEsc(), sliceFn('applyFounding')].join('\n');
157
+ return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB() });
150
158
  }
151
159
 
152
160
  const snapshot = (h) => JSON.stringify({