@bongos/core 1.21.113 → 1.21.115

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 (265) hide show
  1. package/.bongos-core.json +608 -188
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +1 -1
  4. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  5. package/.claude/skills/builder-ship/SKILL.md +1 -0
  6. package/GOVERNMENT.md +128 -11
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +28 -0
  9. package/clients/bongos-client/index.cjs +28 -0
  10. package/clients/bongos-client/index.d.ts +45 -2
  11. package/clients/bongos-client/index.mjs +28 -0
  12. package/docs/adr/0372-an-ask-is-stored-per-docket-matter-and-the-server-refuses-slop.md +33 -0
  13. package/docs/adr/0373-an-ai-session-files-a-blocker-with-an-ask.md +31 -0
  14. package/docs/adr/0374-a-building-lead-is-a-held-position-appointed-by-a-people-matter.md +30 -0
  15. package/docs/adr/0375-who-may-build-without-a-vote-comes-from-the-form-of-government.md +38 -0
  16. package/docs/adr/0376-every-ask-is-routed-to-a-real-seat-on-the-server.md +30 -0
  17. package/docs/adr/0377-governance-is-a-held-position-read-on-me.md +24 -0
  18. package/docs/adr/0378-a-leads-build-outs-are-rows-built-at-once-or-by-a-passed-vote.md +33 -0
  19. package/docs/adr/0379-the-treasury-is-pots-per-goal-a-reserve-and-borrowing-over-real-spend.md +32 -0
  20. package/docs/adr/0380-a-strictness-preset-is-a-bundle-of-laws-switched-by-one-laws-vote.md +26 -0
  21. package/docs/adr/README.md +10 -1
  22. package/docs/api/openapi.json +989 -87
  23. package/docs/api-reference.md +18 -4
  24. package/docs/copy-inventory.md +709 -626
  25. package/docs/copy-registry.json +1673 -858
  26. package/docs/file-map.md +2 -1
  27. package/docs/module-api-changelog.md +4 -0
  28. package/docs/packs/engineer.md +2 -1
  29. package/docs/page-inventory.json +91 -6
  30. package/docs/page-readings.json +4875 -4748
  31. package/modules/builder-settings/law-locks.js +202 -0
  32. package/modules/builder-settings/routes/settings.js +66 -6
  33. package/modules/builder-settings/settings.js +7 -0
  34. package/modules/discord/ship-broadcast.js +22 -13
  35. package/modules/government/asks-db.js +54 -0
  36. package/modules/government/asks.js +269 -0
  37. package/modules/government/board-idea-reads.js +68 -0
  38. package/modules/government/board.js +21 -57
  39. package/modules/government/build-rights.js +148 -0
  40. package/modules/government/buildouts.js +160 -0
  41. package/modules/government/counts.js +81 -0
  42. package/modules/government/db.js +242 -0
  43. package/modules/government/decision-board.js +234 -8
  44. package/modules/government/decision-rules.js +2 -0
  45. package/modules/government/docket.js +114 -6
  46. package/modules/government/law-board.js +131 -0
  47. package/modules/government/laws.js +241 -0
  48. package/modules/government/leads.js +170 -0
  49. package/modules/government/migrations/government_027_laws.sql +143 -0
  50. package/modules/government/migrations/government_028_asks.sql +57 -0
  51. package/modules/government/migrations/government_029_leads.sql +135 -0
  52. package/modules/government/migrations/government_030_buildouts.sql +111 -0
  53. package/modules/government/migrations/government_031_treasury.sql +107 -0
  54. package/modules/government/module.json +2 -1
  55. package/modules/government/pollers/treasury-borrow.js +62 -0
  56. package/modules/government/positions.js +107 -0
  57. package/modules/government/presets.js +149 -0
  58. package/modules/government/routes/government.js +425 -2
  59. package/modules/government/routing.js +154 -0
  60. package/modules/government/treasury-db.js +166 -0
  61. package/modules/government/treasury.js +241 -0
  62. package/modules/hall-ui/public/atlas.html +2 -2
  63. package/modules/hall-ui/public/blockers.html +2 -2
  64. package/modules/hall-ui/public/board-room.html +2 -2
  65. package/modules/hall-ui/public/board-room.js +2 -0
  66. package/modules/hall-ui/public/city-draw.js +604 -11
  67. package/modules/hall-ui/public/city.css +157 -1
  68. package/modules/hall-ui/public/city.html +37 -7
  69. package/modules/hall-ui/public/city.js +441 -21
  70. package/modules/hall-ui/public/city.states.json +110 -5
  71. package/modules/hall-ui/public/collab.html +2 -2
  72. package/modules/hall-ui/public/commands.html +2 -2
  73. package/modules/hall-ui/public/copy-desk.html +2 -2
  74. package/modules/hall-ui/public/craft-mode.js +112 -29
  75. package/modules/hall-ui/public/deploy.html +2 -2
  76. package/modules/hall-ui/public/diagram-viewer.css +12 -0
  77. package/modules/hall-ui/public/diagram-viewer.js +4 -0
  78. package/modules/hall-ui/public/diagrams.html +2 -2
  79. package/modules/hall-ui/public/drachmae.html +2 -2
  80. package/modules/hall-ui/public/fleet.html +2 -2
  81. package/modules/hall-ui/public/gate.html +2 -2
  82. package/modules/hall-ui/public/goal-map.html +2 -2
  83. package/modules/hall-ui/public/goals.html +2 -2
  84. package/modules/hall-ui/public/gov-feeds.js +141 -0
  85. package/modules/hall-ui/public/gov-living.js +1002 -0
  86. package/modules/hall-ui/public/gov-positions.js +97 -0
  87. package/modules/hall-ui/public/government.html +2 -2
  88. package/modules/hall-ui/public/hall-render.js +3 -0
  89. package/modules/hall-ui/public/idea.html +2 -2
  90. package/modules/hall-ui/public/ideas.html +2 -2
  91. package/modules/hall-ui/public/index.html +3 -3
  92. package/modules/hall-ui/public/modules.html +2 -2
  93. package/modules/hall-ui/public/my-agents.css +334 -0
  94. package/modules/hall-ui/public/my-agents.html +72 -0
  95. package/modules/hall-ui/public/my-agents.js +603 -0
  96. package/modules/hall-ui/public/my-agents.states.json +37 -0
  97. package/modules/hall-ui/public/primer.html +2 -2
  98. package/modules/hall-ui/public/profile-connections.js +40 -28
  99. package/modules/hall-ui/public/profile.html +2 -2
  100. package/modules/hall-ui/public/project-settings.html +2 -2
  101. package/modules/hall-ui/public/ranks.html +2 -2
  102. package/modules/hall-ui/public/roadmap.html +2 -2
  103. package/modules/hall-ui/public/room.css +8 -0
  104. package/modules/hall-ui/public/roster.html +2 -2
  105. package/modules/hall-ui/public/sessions.html +2 -2
  106. package/modules/hall-ui/public/settings.html +2 -2
  107. package/modules/hall-ui/public/shell.js +116 -16
  108. package/modules/hall-ui/public/street-card.js +18 -1
  109. package/modules/hall-ui/public/street-deck.css +299 -0
  110. package/modules/hall-ui/public/street-deck.js +1145 -0
  111. package/modules/hall-ui/public/street-depth.js +712 -0
  112. package/modules/hall-ui/public/street-desk.js +5 -2
  113. package/modules/hall-ui/public/street-draw.js +109 -16
  114. package/modules/hall-ui/public/street-inside.js +137 -22
  115. package/modules/hall-ui/public/street-office.js +705 -0
  116. package/modules/hall-ui/public/street.css +161 -11
  117. package/modules/hall-ui/public/street.html +41 -11
  118. package/modules/hall-ui/public/street.js +78 -66
  119. package/modules/hall-ui/public/street.states.json +129 -21
  120. package/modules/hall-ui/public/studio.html +2 -2
  121. package/modules/hall-ui/public/style.css +5 -1
  122. package/modules/hall-ui/public/task.html +2 -2
  123. package/modules/hall-ui/public/thinking.html +2 -2
  124. package/modules/hall-ui/public/tweak-editor.html +2 -2
  125. package/modules/hall-ui/public/watch.html +2 -2
  126. package/modules/hall-ui/public/watch.js +14 -1
  127. package/modules/hall-ui/public/work.html +2 -2
  128. package/modules/hall-ui/records/living-city.md +57 -0
  129. package/modules/hall-ui/records/my-agents.md +21 -0
  130. package/modules/hall-ui/records/oversight-family.md +1 -0
  131. package/modules/hall-ui/records/profile-connections.md +10 -0
  132. package/modules/hall-ui/records/reading-rooms.md +2 -0
  133. package/modules/ideas/routes/blockers.js +92 -3
  134. package/modules/lifecycle/module.json +2 -1
  135. package/modules/lifecycle/moving.js +17 -1
  136. package/modules/lifecycle/routes/moving.js +6 -0
  137. package/modules/lifecycle/routes/project-settings.js +23 -0
  138. package/modules/onboarding/approval-broadcast.js +8 -0
  139. package/modules/onboarding/routes/access-requests.js +16 -6
  140. package/modules/platform-identity/connections.js +13 -2
  141. package/modules/platform-identity/routes/connections.js +7 -1
  142. package/modules/public-landing/public/account-private.states.json +2 -2
  143. package/modules/public-landing/public/account.html +65 -5
  144. package/modules/public-landing/public/account.states.json +3 -3
  145. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  146. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  147. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +13 -0
  148. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  149. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  150. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +13 -0
  151. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  152. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  153. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +13 -0
  154. package/modules/public-landing/public/assets/world.css +4 -4
  155. package/modules/public-landing/public/connections-empty.states.json +14 -0
  156. package/modules/public-landing/public/connections-inactive.states.json +14 -0
  157. package/modules/public-landing/public/connections.html +450 -0
  158. package/modules/public-landing/public/connections.probes.json +414 -0
  159. package/modules/public-landing/public/connections.states.json +17 -0
  160. package/modules/public-landing/public/contact.html +2 -2
  161. package/modules/public-landing/public/guild.html +2 -2
  162. package/modules/public-landing/public/index.html +2 -2
  163. package/modules/public-landing/public/invitations.html +2 -2
  164. package/modules/public-landing/public/privacy.html +2 -2
  165. package/modules/public-landing/public/projects-hall-live.states.json +0 -5
  166. package/modules/public-landing/public/projects.html +169 -6
  167. package/modules/public-landing/public/projects.probes.json +10 -6
  168. package/modules/public-landing/public/projects.states.json +13 -13
  169. package/modules/public-landing/public/terms.html +2 -2
  170. package/modules/ui-design/kit/fixtures/connections-empty.json +12 -0
  171. package/modules/ui-design/kit/fixtures/connections-inactive.json +7 -0
  172. package/modules/ui-design/kit/fixtures/connections.json +22 -0
  173. package/modules/ui-design/kit/serve.js +95 -0
  174. package/package-lock.json +2 -2
  175. package/package.json +1 -1
  176. package/release-notes.json +128 -0
  177. package/scripts/discord/README.md +8 -5
  178. package/scripts/discord/post.js +12 -1
  179. package/scripts/gds/artifact-audit.js +20 -3
  180. package/scripts/gds/ask.js +164 -0
  181. package/scripts/gds/autobongos-verify.js +21 -2
  182. package/scripts/gds/government-walk.js +638 -4
  183. package/scripts/gds/pulse.js +9 -6
  184. package/scripts/gds/ship-escalation.js +22 -3
  185. package/scripts/gds/ship-finish.js +5 -4
  186. package/scripts/gds/ship-merge-gate.js +18 -6
  187. package/scripts/hall-preview/README.md +41 -1
  188. package/scripts/hall-preview/pref-writes.js +140 -0
  189. package/scripts/hall-preview/refresh-fixtures.js +101 -7
  190. package/scripts/hall-preview/scrub-identity.js +366 -7
  191. package/scripts/hall-preview/server.js +27 -1
  192. package/scripts/render-diagrams.sh +4 -2
  193. package/src/bongos/auth-idp.js +6 -1
  194. package/src/bongos/project-settings.js +41 -0
  195. package/src/bongos/route-rank-check.js +27 -0
  196. package/src/bongos/routes/me.js +68 -7
  197. package/src/bongos/serve-internal.js +5 -1
  198. package/src/module-api.js +7 -1
  199. package/src/platform-server.js +10 -0
  200. package/tests/application_lifecycle.mjs +47 -5
  201. package/tests/conductor_main_e2e.mjs +21 -0
  202. package/tests/connections_api.mjs +18 -0
  203. package/tests/craft_mode_nav.mjs +5 -3
  204. package/tests/craft_mode_tallies.mjs +52 -16
  205. package/tests/discord_approvals.mjs +11 -0
  206. package/tests/discord_channels.mjs +20 -0
  207. package/tests/fixtures/gov-seated.mjs +64 -0
  208. package/tests/fixtures/government-ask-cards.json +1284 -0
  209. package/tests/government_act_alone_buildings.mjs +1 -0
  210. package/tests/government_agent_asks.mjs +322 -0
  211. package/tests/government_asks.mjs +408 -0
  212. package/tests/government_build_rights.mjs +253 -0
  213. package/tests/government_buildouts.mjs +382 -0
  214. package/tests/government_counts.mjs +143 -0
  215. package/tests/government_decision_rules.mjs +5 -1
  216. package/tests/government_laws.mjs +488 -0
  217. package/tests/government_laws_session.mjs +321 -0
  218. package/tests/government_leads.mjs +375 -0
  219. package/tests/government_positions.mjs +127 -0
  220. package/tests/government_presets.mjs +215 -0
  221. package/tests/government_routes.mjs +8 -1
  222. package/tests/government_routing.mjs +118 -0
  223. package/tests/government_seed.mjs +5 -0
  224. package/tests/government_treasury.mjs +394 -0
  225. package/tests/governor_docket.mjs +26 -2
  226. package/tests/hall_buildouts.mjs +208 -0
  227. package/tests/hall_city.mjs +14 -6
  228. package/tests/hall_city_living.mjs +617 -0
  229. package/tests/hall_gov_counts.mjs +336 -0
  230. package/tests/hall_gov_living.mjs +636 -0
  231. package/tests/hall_gov_real_site.mjs +655 -0
  232. package/tests/hall_matter_card.mjs +6 -1
  233. package/tests/hall_my_agents.mjs +481 -0
  234. package/tests/hall_nav.mjs +107 -4
  235. package/tests/hall_page_gate_map.mjs +4 -0
  236. package/tests/hall_preview_governor_fixtures.mjs +30 -3
  237. package/tests/hall_preview_scrub.mjs +59 -1
  238. package/tests/hall_preview_security.mjs +169 -0
  239. package/tests/hall_reading_world.mjs +44 -0
  240. package/tests/hall_roster_world.mjs +6 -2
  241. package/tests/hall_street.mjs +18 -2
  242. package/tests/hall_street_amend.mjs +1 -1
  243. package/tests/hall_street_deck.mjs +609 -0
  244. package/tests/hall_street_depth.mjs +559 -0
  245. package/tests/hall_street_desk.mjs +1 -1
  246. package/tests/hall_street_inside.mjs +29 -5
  247. package/tests/hall_tweak_editor.mjs +4 -1
  248. package/tests/hub_account_page.mjs +94 -3
  249. package/tests/hub_connections_page.mjs +507 -0
  250. package/tests/hub_invite_badge.mjs +1 -1
  251. package/tests/idp_device_exchange.mjs +18 -0
  252. package/tests/landing_legal_pages.mjs +8 -5
  253. package/tests/landing_page.mjs +23 -4
  254. package/tests/nav_permission_atoms.mjs +4 -1
  255. package/tests/profile_connections_ui.mjs +88 -25
  256. package/tests/projects_hub.mjs +15 -0
  257. package/tests/projects_hub_pre_uat.mjs +4 -2
  258. package/tests/render_check.mjs +2 -2
  259. package/tests/ship_broadcast_routing.mjs +57 -16
  260. package/tests/ship_broadcast_server.mjs +40 -7
  261. package/tests/ship_merge_gate.mjs +19 -0
  262. package/tests/ui_design_kit.mjs +2 -2
  263. package/tests/watch_applications_queue.mjs +23 -0
  264. package/tests/wizard_physics.mjs +1 -1
  265. package/tests/wizard_physics_more.mjs +133 -0
@@ -0,0 +1,507 @@
1
+ // tests/hub_connections_page.mjs — the hub's /connections page (task 1004278; the
2
+ // owner's calls of 2026-09-25, ADR 0337 D5.4: the hub is the primary home for
3
+ // connections).
4
+ //
5
+ // The page renders the platform-identity connection routes (ADR 0202, 0207) and
6
+ // adds nothing to them: no scoring, no data, no new route. What this file pins:
7
+ // • the markup: the hub's bar with Connections as the one current place, the
8
+ // signed-out door, the not-active and unreadable lines, the four lists in order,
9
+ // and the handle field with the route's own limits
10
+ // • the privacy rules the hall's panel keeps (profile-connections.js): a sent ask
11
+ // claims "Request sent." and no more, `seen_at` never reaches the screen, every
12
+ // count is the length of the list beside it, and the server's suggestions and
13
+ // reasons are drawn as they come
14
+ // • the page RUN in a vm: signed out it reads nothing; signed in it reads the three
15
+ // lists once; a 403 is the viewer's own inactive profile, a failed list read is
16
+ // an alert and never an empty page, a failed suggestions read only drops that
17
+ // panel; Accept, Decline, Remove and Withdraw each make their one call and the
18
+ // page re-reads; asking by handle strips an @, sends the note, and says each of
19
+ // the route's refusals in plain words
20
+ // • the Connections item on every page that wears the hub's bar (apex-only where the
21
+ // page also serves a child)
22
+ // • the route: served on the hub's apex; never on a child, the builders host or the
23
+ // status host
24
+ // • the kit: the stub's held graph answers as the routes do (no id on a sent ask, a
25
+ // silent decline, one 404), and the states and probes files exist
26
+ //
27
+ // DB-free. Run: node --test tests/hub_connections_page.mjs
28
+
29
+ import assert from 'node:assert/strict';
30
+ import { test, before, after } from 'node:test';
31
+ import http from 'node:http';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import vm from 'node:vm';
35
+ import { spawn } from 'node:child_process';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { createRequire } from 'node:module';
38
+
39
+ const require = createRequire(import.meta.url);
40
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
42
+ const read = (f) => fs.readFileSync(path.join(LANDING, f), 'utf8').replace(/\r\n/g, '\n');
43
+ const PAGE = read('connections.html');
44
+ const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
45
+ // the code alone: the comments name what they rule out
46
+ const CODE = SCRIPT.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
47
+ const STYLE = PAGE.split('<style>')[1].split('</style>')[0].replace(/\/\*[\s\S]*?\*\//g, '');
48
+ const MAIN = PAGE.split('<main class="acct" id="conn">')[1].split('</main>')[0];
49
+ const ROUTES = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'connections.js'), 'utf8');
50
+ const DOMAIN = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'connections.js'), 'utf8');
51
+
52
+ // ── 1. the markup ────────────────────────────────────────────────────────────
53
+
54
+ test('the page wears the hub’s bar, with Connections as the one current place; dark only, no page :root, not indexed', () => {
55
+ assert.match(PAGE, /<html lang="en" data-mode="dark">/);
56
+ assert.match(PAGE, /<meta name="robots" content="noindex">/);
57
+ assert.match(PAGE, /<title>\{\{productName\}\} — Connections<\/title>/);
58
+ assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/, 'the rows, field and buttons are cosmos.css’s own rules');
59
+ assert.match(PAGE, /<script src="\/assets\/hub-bar\.js"><\/script>/, 'the bar runs on the shared asset');
60
+ assert.match(PAGE, /<script src="\/assets\/invite-badge\.js"><\/script>/, 'the waiting count rides every page that wears the bar');
61
+ assert.doesNotMatch(STYLE, /:root/, 'the pack’s injected theme owns the tokens');
62
+ assert.doesNotMatch(STYLE, /#[0-9a-fA-F]{3,8}\b/, 'every colour is a token or a mix of one');
63
+ assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/connections" aria-current="page">Connections']);
64
+ assert.match(PAGE, /<a class="skip vh" href="#conn">Skip to content<\/a>/);
65
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/connections&amp;repos=1">Sign in<\/a>/);
66
+ assert.equal((PAGE.match(/<!--apex-only:start-->/g) || []).length, 1, 'hub-only: the footer’s platform sentence is the one apex-only region');
67
+ assert.ok(PAGE.indexOf('<!--apex-only:start-->') > PAGE.indexOf('<footer class="foot">'));
68
+ });
69
+
70
+ test('the bar is /invitations’ bar but for the sign-in return and the current place', () => {
71
+ const bar = (html) => html.slice(html.indexOf('<header class="shell bar">'), html.indexOf('<main '))
72
+ .replace(/return=\/\w+&amp;/g, 'return=X&amp;').replace(/ aria-current="page"/g, '');
73
+ assert.equal(bar(PAGE), bar(read('invitations.html')));
74
+ });
75
+
76
+ test('the body: a door, a loading line, the inactive and unreadable lines, then the four lists in order', () => {
77
+ assert.match(MAIN, /<h1>connections<\/h1>/);
78
+ assert.match(MAIN, /it grants no rank and no access to anything/, 'a connection is never authority (ADR 0016, ADR 0202)');
79
+ assert.match(MAIN, /<section class="aDoor" id="door" hidden aria-labelledby="door-h">[\s\S]*?href="\/api\/bongos\/auth\/web\/start\?return=\/connections&amp;repos=1"/, 'signed out, the door returns here');
80
+ assert.match(MAIN, /<p class="loading" id="loading">/);
81
+ assert.match(MAIN, /<p class="mineEmpty" id="inactive" hidden>/);
82
+ assert.match(MAIN, /<p class="projMsg err" id="readFailed" role="alert" hidden>/);
83
+ assert.match(MAIN, /<p class="projMsg" id="connMsg" role="status" aria-live="polite" hidden><\/p>/);
84
+ assert.match(MAIN, /<div class="mineExtra" id="connBody" hidden>/, 'nothing but the door shows until who is signed in is known');
85
+ const order = ['incomingList', 'connList', 'suggestList', 'outgoingList'].map((id) => MAIN.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
86
+ assert.ok(order.every((i) => i > 0), 'each list is real markup the script only fills');
87
+ assert.deepEqual([...order].sort((a, b) => a - b), order);
88
+ assert.match(MAIN, /<section id="connAsks" aria-labelledby="connAsksH" hidden>/, 'the asking section shows only when someone asks');
89
+ assert.match(MAIN, /<section id="connSuggest" aria-labelledby="connSuggestH" hidden>/, 'the suggestions panel shows only when the server suggests');
90
+ assert.match(MAIN, /<h2 id="connSuggestH">Builders you may know<\/h2>/);
91
+ assert.match(MAIN, /<button id="askBtn" type="button" class="btn small">Connect<\/button>/);
92
+ });
93
+
94
+ test('the handle and note limits are the route’s own (boundary-forced copies)', () => {
95
+ const routeHandle = ROUTES.match(/handle: \{ required: true, type: 'string', minLength: 1, maxLength: (\d+) \}/);
96
+ const domainNote = DOMAIN.match(/const NOTE_MAX_CHARS = (\d+);/);
97
+ assert.ok(routeHandle && domainNote);
98
+ assert.match(SCRIPT, new RegExp(`var HANDLE_MAX = ${routeHandle[1]};`));
99
+ assert.match(SCRIPT, new RegExp(`var NOTE_MAX = ${domainNote[1]};`));
100
+ assert.match(MAIN, new RegExp(`<textarea id="askNote" class="mineJoinNote" rows="2" maxlength="${domainNote[1]}"`));
101
+ });
102
+
103
+ test('the script builds no markup from strings, calls only the connection routes, and never reads seen_at', () => {
104
+ assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
105
+ assert.doesNotMatch(CODE, /seen_at/, 'when a request was read never reaches the screen (ADR 0202 D2)');
106
+ const paths = [...CODE.matchAll(/(?:read\(|API \+ |act\('(?:POST|DELETE)', )'(\/[^']*)'/g)].map((m) => m[1]);
107
+ assert.equal(paths.length, 8, 'three reads, accept, decline, remove, withdraw and the ask');
108
+ for (const p of paths) assert.match(p, /^\/connections(\/|$)/, `${p} is a connections route`);
109
+ assert.doesNotMatch(CODE, /\.count\b|_count\b/, 'every count is the length of the list beside it (ADR 0171 D4)');
110
+ assert.doesNotMatch(CODE, /\.sort\(|\.score\b/, 'the server ranks suggestions; the page adds no order of its own');
111
+ });
112
+
113
+ test('every refusal the page has words for is one the route can send', () => {
114
+ const block = SCRIPT.match(/var CONNECT_REFUSED = \{([\s\S]*?)\};/)[1];
115
+ const codes = [...block.matchAll(/^\s*(\w+):/gm)].map((m) => m[1]);
116
+ assert.deepEqual(codes.sort(), ['cannot_connect_to_self', 'connection_already_open', 'connection_target_not_found', 'profile_provisional']);
117
+ for (const c of codes) assert.match(ROUTES, new RegExp(`'${c}'`), `routes/connections.js sends ${c}`);
118
+ });
119
+
120
+ // ── 2. the page, run ─────────────────────────────────────────────────────────
121
+
122
+ function el(tag) {
123
+ const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, value: '', attrs: {}, _on: {}, parent: null };
124
+ Object.defineProperty(n, 'textContent', {
125
+ get() { return n._text + n.children.map((c) => c.textContent).join(''); },
126
+ set(v) { n._text = String(v); n.children = []; },
127
+ });
128
+ n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
129
+ n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
130
+ n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
131
+ return n;
132
+ }
133
+ const settle = async () => { for (let i = 0; i < 60; i++) await Promise.resolve(); };
134
+ const kids = (n) => n.children;
135
+ const find = (n, cls) => {
136
+ for (const c of n.children) {
137
+ if (String(c.className).split(' ').includes(cls)) return c;
138
+ const d = find(c, cls);
139
+ if (d) return d;
140
+ }
141
+ return null;
142
+ };
143
+ const all = (n, cls, out = []) => {
144
+ for (const c of n.children) { if (String(c.className).split(' ').includes(cls)) out.push(c); all(c, cls, out); }
145
+ return out;
146
+ };
147
+
148
+ const GRAPH = () => ({
149
+ '/connections': { status: 200, body: { connections: [
150
+ { id: '41', connected_at: '2026-09-30T10:00:00Z', handle: 'mira', display_name: 'Mira Okafor', avatar_url: 'https://avatars.example/u/1' },
151
+ { id: '42', connected_at: null, handle: 'juno', display_name: null, avatar_url: 'javascript:alert(1)' },
152
+ ], count: 99 } },
153
+ '/connections/requests': { status: 200, body: {
154
+ incoming: [{ id: '51', note: 'We met on <b>Tidepool</b>.', created_at: '2026-10-10T00:00:00Z', seen_at: '2026-10-11T08:00:00Z', handle: 'priya', display_name: 'Priya Nair', avatar_url: null }],
155
+ outgoing: [{ id: '61', note: null, created_at: '2026-10-07T00:00:00Z', handle: 'leo-b', display_name: 'Leo Brandt', avatar_url: null }],
156
+ incoming_count: 7, outgoing_count: 7,
157
+ } },
158
+ '/connections/suggestions': { status: 200, body: { suggestions: [
159
+ { handle: 'sana', display_name: 'Sana Iqbal', avatar_url: null, reason: '2 connections in common' },
160
+ { handle: 'owen-c', display_name: 'Owen Clarke', avatar_url: null, reason: 'You both build on Tidepool' },
161
+ ], count: 2 } },
162
+ });
163
+
164
+ async function boot({ me = { builder: { github_login: 'builder' } }, reads = GRAPH(), writes = [] } = {}) {
165
+ const ids = new Map();
166
+ for (const id of [...PAGE.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])) {
167
+ const n = el('x');
168
+ n.id = id;
169
+ const tag = PAGE.match(new RegExp(`<(\\w+)[^>]* id="${id}"[^>]*>`));
170
+ n.hidden = !!(tag && / hidden[ >]/.test(tag[0]));
171
+ ids.set(id, n);
172
+ }
173
+ const requests = [];
174
+ const queued = writes.slice();
175
+ const fetch = (url, init = {}) => {
176
+ const method = init.method || 'GET';
177
+ requests.push({ url, method, body: init.body ? JSON.parse(init.body) : undefined, credentials: init.credentials });
178
+ const a = method === 'GET' ? reads[url.replace(/^\/api\/bongos/, '')] : queued.shift();
179
+ if (!a || a.reject) return Promise.reject(new Error('offline'));
180
+ return Promise.resolve({ ok: a.status >= 200 && a.status < 300, status: a.status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
181
+ };
182
+ const document = { getElementById: (id) => ids.get(id) || null, createElement: el };
183
+ const window = { HubBar: { paint: () => {}, ready: Promise.resolve(me) } };
184
+ const sandbox = { window, document, fetch, console, Promise, JSON, String, Object, Array, URL, Date, isNaN, encodeURIComponent };
185
+ vm.createContext(sandbox);
186
+ vm.runInContext(SCRIPT, sandbox, { filename: 'connections.html' });
187
+ await settle();
188
+ const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
189
+ const key = async (n, k) => { const e = { key: k, preventDefault() { e.prevented = true; } }; for (const f of n._on.keydown || []) f(e); await settle(); return e; };
190
+ const gets = () => requests.filter((r) => r.method === 'GET').map((r) => r.url);
191
+ return { ids, requests, gets, click, key, $: (id) => ids.get(id) };
192
+ }
193
+ const button = (row, label) => all(row, 'btn').find((b) => b.textContent === label);
194
+
195
+ test('signed out: the door, nothing read', async () => {
196
+ const ui = await boot({ me: null });
197
+ assert.equal(ui.$('door').hidden, false);
198
+ assert.equal(ui.$('connBody').hidden, true);
199
+ assert.equal(ui.$('loading').hidden, true);
200
+ assert.deepEqual(ui.requests, [], 'GET /me is the bar’s; this page asks nothing of a stranger');
201
+ });
202
+
203
+ test('signed in: the three lists read once, each painted from the server’s answer and nothing else', async () => {
204
+ const ui = await boot();
205
+ assert.deepEqual(ui.gets(), ['/api/bongos/connections', '/api/bongos/connections/requests', '/api/bongos/connections/suggestions']);
206
+ assert.ok(ui.requests.every((r) => r.credentials === 'same-origin'));
207
+ assert.equal(ui.$('connBody').hidden, false);
208
+ assert.equal(ui.$('door').hidden, true);
209
+ assert.equal(ui.$('loading').hidden, true);
210
+ assert.equal(ui.$('connMsg').hidden, true, 'the status line says nothing yet, so it takes no room');
211
+
212
+ // asking to connect: the note as typed (text, never markup), Accept and Decline, no "read" time
213
+ assert.equal(ui.$('connAsks').hidden, false);
214
+ const ask = kids(ui.$('incomingList'))[0];
215
+ assert.equal(find(ask, 'mineRow__name').textContent, 'Priya Nair');
216
+ assert.equal(find(ask, 'mineRow__host').textContent, '@priya');
217
+ assert.equal(find(ask, 'connNote').textContent, 'We met on <b>Tidepool</b>.', 'textContent: the asker’s words are never markup');
218
+ assert.deepEqual(all(ask, 'btn').map((b) => b.textContent), ['Accept', 'Decline']);
219
+ const everything = ['incomingList', 'connList', 'suggestList', 'outgoingList'].map((id) => ui.$(id).textContent).join(' ');
220
+ assert.doesNotMatch(everything, /Oct 11|2026-10-11|08:00/, 'seen_at never reaches the screen');
221
+
222
+ // the connections: the count is the list’s length, never the server’s count field
223
+ assert.equal(ui.$('connCount').textContent, '2 connections.');
224
+ const [mira, juno] = kids(ui.$('connList'));
225
+ assert.equal(find(mira, 'connAv').src, 'https://avatars.example/u/1');
226
+ assert.match(find(mira, 'mineRow__hint').textContent, /^Connected /);
227
+ assert.equal(find(juno, 'mineRow__name').textContent, 'juno', 'no display name: the handle stands in');
228
+ assert.equal(find(juno, 'connAv').tag, 'span', 'an avatar that is not http(s) is never loaded');
229
+ assert.equal(find(juno, 'mineRow__hint'), null, 'no date, no "Connected" line');
230
+ assert.deepEqual(all(ui.$('connList'), 'btn').map((b) => b.textContent), ['Remove', 'Remove']);
231
+ assert.ok(kids(ui.$('connList')).every((r) => /\bconnOne\b/.test(r.className)), 'one button: kept beside the name on a phone');
232
+
233
+ // builders you may know: the server’s order and its reasons, verbatim
234
+ assert.equal(ui.$('connSuggest').hidden, false);
235
+ assert.deepEqual(kids(ui.$('suggestList')).map((r) => find(r, 'mineRow__hint').textContent), ['2 connections in common', 'You both build on Tidepool']);
236
+ assert.deepEqual(kids(ui.$('suggestList')).map((r) => find(r, 'mineRow__host').textContent), ['@sana', '@owen-c']);
237
+
238
+ // what you sent: "Request sent" and Withdraw
239
+ assert.equal(ui.$('sentH').hidden, false);
240
+ const sent = kids(ui.$('outgoingList'))[0];
241
+ assert.equal(find(sent, 'mineRow__hint').textContent, 'Request sent');
242
+ assert.deepEqual(all(sent, 'btn').map((b) => b.textContent), ['Withdraw']);
243
+ });
244
+
245
+ test('nothing yet: no asking section, no sent heading, and the count line says how to start', async () => {
246
+ const reads = GRAPH();
247
+ reads['/connections'].body = { connections: [], count: 0 };
248
+ reads['/connections/requests'].body = { incoming: [], outgoing: [] };
249
+ reads['/connections/suggestions'].body = { suggestions: [] };
250
+ const ui = await boot({ reads });
251
+ assert.equal(ui.$('connAsks').hidden, true);
252
+ assert.equal(ui.$('sentH').hidden, true);
253
+ assert.equal(ui.$('connSuggest').hidden, true, 'no suggestions, no empty shell');
254
+ assert.equal(ui.$('connCount').textContent, 'No connections yet. Accept a request above, or ask a builder below.');
255
+ });
256
+
257
+ test('a 403 is the viewer’s own inactive profile; a failed list read is an alert, never an empty page', async () => {
258
+ const inactive = GRAPH();
259
+ inactive['/connections'] = { status: 403, body: { error: { code: 'profile_provisional' } } };
260
+ inactive['/connections/requests'] = { status: 403, body: { error: { code: 'profile_provisional' } } };
261
+ let ui = await boot({ reads: inactive });
262
+ assert.equal(ui.$('inactive').hidden, false);
263
+ assert.equal(ui.$('connBody').hidden, true);
264
+
265
+ const broken = GRAPH();
266
+ broken['/connections/requests'] = { status: 500, body: {} };
267
+ ui = await boot({ reads: broken });
268
+ assert.equal(ui.$('readFailed').hidden, false);
269
+ assert.equal(ui.$('connBody').hidden, true, '"no connections" would be a claim the page cannot make');
270
+
271
+ const expired = GRAPH();
272
+ expired['/connections'] = { status: 401, body: {} };
273
+ ui = await boot({ reads: expired });
274
+ assert.equal(ui.$('door').hidden, false, 'a session that ended while the page loaded gets the door back');
275
+ });
276
+
277
+ test('a failed suggestions read drops only that panel', async () => {
278
+ const reads = GRAPH();
279
+ reads['/connections/suggestions'] = { status: 500, body: {} };
280
+ const ui = await boot({ reads });
281
+ assert.equal(ui.$('connBody').hidden, false);
282
+ assert.equal(ui.$('connSuggest').hidden, true);
283
+ assert.equal(kids(ui.$('connList')).length, 2);
284
+ });
285
+
286
+ test('Accept, Decline, Remove and Withdraw: one call each, then the page re-reads', async () => {
287
+ const cases = [
288
+ ['incomingList', 'Accept', 'POST', '/api/bongos/connections/51/accept', { status: 200, body: { status: 'accepted' } }, 'Connected.'],
289
+ ['incomingList', 'Decline', 'POST', '/api/bongos/connections/51/decline', { status: 204 }, 'Declined. They are not told.'],
290
+ ['connList', 'Remove', 'DELETE', '/api/bongos/connections/41', { status: 200, body: { outcome: 'removed' } }, 'Connection removed.'],
291
+ ['outgoingList', 'Withdraw', 'DELETE', '/api/bongos/connections/61', { status: 200, body: { outcome: 'cancelled' } }, 'Request withdrawn.'],
292
+ ];
293
+ for (const [list, label, method, url, answer, said] of cases) {
294
+ const ui = await boot({ writes: [answer] });
295
+ await ui.click(button(kids(ui.$(list))[0], label));
296
+ const write = ui.requests.find((r) => r.method !== 'GET');
297
+ assert.deepEqual([write.method, write.url, write.body], [method, url, undefined], `${label} sends no body`);
298
+ assert.equal(ui.$('connMsg').textContent, said);
299
+ assert.equal(ui.$('connMsg').className, 'projMsg ok');
300
+ assert.equal(ui.$('connMsg').hidden, false);
301
+ assert.equal(ui.gets().length, 6, `${label}: the page re-reads the three lists rather than patching rows`);
302
+ }
303
+ });
304
+
305
+ test('a row the server no longer has is one 404, said once; a dropped network keeps the buttons usable', async () => {
306
+ let ui = await boot({ writes: [{ status: 404, body: { error: { code: 'connection_request_not_found' } } }] });
307
+ await ui.click(button(kids(ui.$('incomingList'))[0], 'Accept'));
308
+ assert.equal(ui.$('connMsg').textContent, 'That request is no longer there.');
309
+ assert.equal(ui.$('connMsg').className, 'projMsg err');
310
+
311
+ ui = await boot({ writes: [{ reject: true }] });
312
+ const row = kids(ui.$('connList'))[0];
313
+ const remove = button(row, 'Remove');
314
+ await ui.click(remove);
315
+ assert.equal(remove.disabled, false);
316
+ assert.match(ui.$('connMsg').textContent, /did not go through/);
317
+ });
318
+
319
+ test('Connect on a builder you may know: "Request sent." is the whole claim, then the page re-reads', async () => {
320
+ const ui = await boot({ writes: [{ status: 201, body: { status: 'pending' } }] });
321
+ await ui.click(button(kids(ui.$('suggestList'))[0], 'Connect'));
322
+ const write = ui.requests.find((r) => r.method === 'POST');
323
+ assert.deepEqual([write.url, write.body], ['/api/bongos/connections', { handle: 'sana' }]);
324
+ assert.equal(ui.$('connMsg').textContent, 'Request sent.');
325
+ assert.equal(ui.gets().length, 6);
326
+ });
327
+
328
+ test('asking by handle: an @ is dropped, the note rides, the field clears, and each refusal is said plainly', async () => {
329
+ let ui = await boot({ writes: [{ status: 201, body: { status: 'pending' } }] });
330
+ ui.$('askHandle').value = ' @nadia ';
331
+ ui.$('askNote').value = ' We met on Tidepool. ';
332
+ await ui.click(ui.$('askBtn'));
333
+ const sent = ui.requests.find((r) => r.method === 'POST');
334
+ assert.deepEqual(sent.body, { handle: 'nadia', note: 'We met on Tidepool.' });
335
+ assert.equal(ui.$('askStatus').textContent, 'Request sent.');
336
+ assert.equal(ui.$('askStatus').className, 'projMsg ok');
337
+ assert.equal(ui.$('askHandle').value, '');
338
+ assert.equal(ui.$('askNote').value, '');
339
+ assert.equal(ui.gets().length, 6);
340
+
341
+ const refusals = [
342
+ [409, 'connection_already_open', 'There is already a request or a connection between you.'],
343
+ [404, 'connection_target_not_found', 'No builder with that handle can be asked right now.'],
344
+ [400, 'cannot_connect_to_self', 'That is your own handle.'],
345
+ [403, 'profile_provisional', 'Your profile is not active yet, so you cannot ask anyone to connect.'],
346
+ [500, 'connection_request_failed', 'That request could not be sent. Check the handle and try again.'],
347
+ ];
348
+ for (const [status, code, said] of refusals) {
349
+ ui = await boot({ writes: [{ status, body: { error: { code } } }] });
350
+ ui.$('askHandle').value = 'nadia';
351
+ await ui.click(ui.$('askBtn'));
352
+ assert.equal(ui.$('askStatus').textContent, said, code);
353
+ assert.equal(ui.$('askStatus').className, 'projMsg err');
354
+ assert.equal(ui.$('askHandle').value, 'nadia', 'a refused ask keeps what was typed');
355
+ assert.equal(ui.$('askBtn').disabled, false);
356
+ }
357
+ });
358
+
359
+ test('asking by handle: nothing is sent for an empty or over-long handle; Enter asks too', async () => {
360
+ let ui = await boot();
361
+ await ui.click(ui.$('askBtn'));
362
+ assert.equal(ui.$('askStatus').textContent, 'Type a builder’s handle first.');
363
+ ui.$('askHandle').value = 'x'.repeat(40);
364
+ await ui.click(ui.$('askBtn'));
365
+ assert.equal(ui.$('askStatus').textContent, 'A handle is at most 39 characters.');
366
+ assert.equal(ui.requests.filter((r) => r.method === 'POST').length, 0);
367
+
368
+ ui = await boot({ writes: [{ status: 201, body: { status: 'pending' } }] });
369
+ ui.$('askHandle').value = 'nadia';
370
+ const e = await ui.key(ui.$('askHandle'), 'Enter');
371
+ assert.ok(e.prevented);
372
+ assert.deepEqual(ui.requests.find((r) => r.method === 'POST').body, { handle: 'nadia' }, 'no note: the key is left out');
373
+ });
374
+
375
+ // ── 3. every bar names the page ──────────────────────────────────────────────
376
+
377
+ test('the profile band carries Connections between Invitations and Account on every page that wears the hub’s bar', () => {
378
+ const item = '<li class="bandCell"><a class="bandT" href="/connections">Connections</a></li>';
379
+ for (const f of ['projects.html', 'account.html', 'invitations.html', 'guild.html', 'privacy.html', 'terms.html', 'contact.html', 'index.html']) {
380
+ const html = read(f);
381
+ const at = html.indexOf(item);
382
+ assert.ok(at > 0, `${f} names /connections`);
383
+ assert.ok(html.lastIndexOf('>Invitations</a></li>', at) > 0 && html.indexOf('href="/account"', at) === at + item.length + '<li class="bandCell"><a class="bandT" '.length, `${f}: between Invitations and Account`);
384
+ const apexOnly = /<!--apex-only:start-->/.test(html.slice(html.indexOf('id="menuProfile"'), at));
385
+ if (['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html'].includes(f)) {
386
+ assert.ok(apexOnly, `${f} also serves a child, which has no /connections: the item sits in the apex-only region`);
387
+ }
388
+ }
389
+ });
390
+
391
+ // ── 4. the route ─────────────────────────────────────────────────────────────
392
+
393
+ const { buildPlatformApp, stripApexOnly } = require('../src/platform-server.js');
394
+ const modules = require('../src/modules.js');
395
+ let server;
396
+ let port;
397
+ before(async () => {
398
+ server = http.createServer(buildPlatformApp());
399
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
400
+ port = server.address().port;
401
+ });
402
+ after(async () => {
403
+ modules.setModuleOverrides({});
404
+ await new Promise((resolve) => server.close(resolve));
405
+ });
406
+ function get(pathname, host) {
407
+ return new Promise((resolve, reject) => {
408
+ http.get({ host: '127.0.0.1', port, path: pathname, headers: { Host: host } }, (res) => {
409
+ let body = '';
410
+ res.on('data', (c) => (body += c));
411
+ res.on('end', () => resolve({ status: res.statusCode, body }));
412
+ }).on('error', reject);
413
+ });
414
+ }
415
+
416
+ test('GET /connections: served on the hub’s apex, stamped; never on a child, the builders host or the status host', async () => {
417
+ modules.setModuleOverrides({ 'platform-identity': false });
418
+ const child = await get('/connections', 'game.test.local');
419
+ assert.equal(child.status, 404, 'a child instance has none of the routes this page reads, so no page');
420
+ modules.setModuleOverrides({ 'platform-identity': true });
421
+ const hub = await get('/connections', 'game.test.local');
422
+ assert.equal(hub.status, 200);
423
+ assert.match(hub.body, /— Connections<\/title>/);
424
+ assert.match(hub.body, /window\.__BRANDING__=/);
425
+ assert.doesNotMatch(hub.body, /\{\{[a-zA-Z]+\}\}/);
426
+ assert.match(hub.body, /\/assets\/hub-bar\.js\?v=[0-9a-f]{8}/, 'the shared bar asset is content-stamped');
427
+ for (const host of ['builders.test.local', 'status.test.local']) {
428
+ const other = await get('/connections', host);
429
+ assert.doesNotMatch(other.body, /— Connections<\/title>/, `${host} must not serve /connections`);
430
+ }
431
+ });
432
+
433
+ test('a child’s pages drop the Connections item with the rest of the apex-only region', () => {
434
+ for (const f of ['privacy.html', 'terms.html', 'contact.html', 'index.html', 'projects.html']) {
435
+ assert.doesNotMatch(stripApexOnly(read(f), false), /href="\/connections"/, f);
436
+ }
437
+ });
438
+
439
+ // ── 5. the kit ───────────────────────────────────────────────────────────────
440
+
441
+ test('the kit renders the filled page, the empty page, the inactive profile and the door, and probes every action', () => {
442
+ const json = (f) => JSON.parse(read(f));
443
+ const states = json('connections.states.json');
444
+ assert.equal(states.page, '/connections');
445
+ assert.deepEqual(states.modes, ['dark']);
446
+ for (const s of ['out', 'in', 'in-profile-band']) assert.ok(states.states[s], `kit state ${s}`);
447
+ for (const [file, fixture] of [['connections-empty.states.json', 'connections-empty'], ['connections-inactive.states.json', 'connections-inactive']]) {
448
+ const v = json(file);
449
+ assert.equal(v.stub.fixtures.connections, fixture);
450
+ assert.ok(fs.existsSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', fixture + '.json')), `fixture ${fixture}`);
451
+ }
452
+ const probes = json('connections.probes.json').probes.map((p) => p.name).join('\n');
453
+ for (const word of ['signed out', 'Accept', 'Decline', 'Withdraw', 'Remove', 'builder you may know', 'asking by handle', 'own handle']) {
454
+ assert.ok(probes.includes(word), `a probe covers ${word}`);
455
+ }
456
+ });
457
+
458
+ function stub(env = {}) {
459
+ return new Promise((resolve, reject) => {
460
+ const child = spawn(process.execPath, [path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js')], {
461
+ env: { ...process.env, AUTH: '1', STUB_PORT: '0', ...env }, stdio: ['ignore', 'pipe', 'inherit'],
462
+ });
463
+ let out = '';
464
+ child.stdout.on('data', (c) => {
465
+ out += c;
466
+ const m = out.match(/stub on (http:\/\/127\.0\.0\.1:\d+)\//);
467
+ if (m) resolve({ origin: m[1], stop: () => child.kill() });
468
+ });
469
+ child.on('exit', (code) => reject(new Error(`stub exited ${code}: ${out}`)));
470
+ });
471
+ }
472
+ async function call(origin, method, p, body) {
473
+ const res = await globalThis.fetch(origin + '/api/bongos' + p, {
474
+ method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined,
475
+ });
476
+ const text = await res.text();
477
+ return { status: res.status, body: text ? JSON.parse(text) : undefined };
478
+ }
479
+
480
+ test('the kit stub answers the connection routes the way the routes do', async () => {
481
+ const s = await stub();
482
+ try {
483
+ const reqs = await call(s.origin, 'GET', '/connections/requests');
484
+ assert.ok(reqs.body.outgoing.every((r) => !('seen_at' in r)), 'the outbox never carries seen_at');
485
+ assert.equal(reqs.body.incoming_count, reqs.body.incoming.length);
486
+ const asked = await call(s.origin, 'POST', '/connections', { handle: 'nadia' });
487
+ assert.deepEqual([asked.status, asked.body], [201, { status: 'pending' }], 'no id, nothing to difference');
488
+ assert.equal((await call(s.origin, 'POST', '/connections', { handle: 'nadia' })).status, 409);
489
+ assert.equal((await call(s.origin, 'POST', '/connections', { handle: 'nobody-here' })).body.error.code, 'connection_target_not_found');
490
+ assert.equal((await call(s.origin, 'POST', '/connections', { handle: 'builder' })).body.error.code, 'cannot_connect_to_self');
491
+ assert.equal((await call(s.origin, 'POST', '/connections', { handle: '' })).status, 400);
492
+ const declined = await call(s.origin, 'POST', '/connections/52/decline');
493
+ assert.deepEqual([declined.status, declined.body], [204, undefined], 'a decline is silent');
494
+ assert.equal((await call(s.origin, 'POST', '/connections/52/accept')).status, 404, 'a declined request is gone');
495
+ assert.equal((await call(s.origin, 'POST', '/connections/51/accept')).status, 200);
496
+ const list = await call(s.origin, 'GET', '/connections');
497
+ assert.equal(list.body.connections[0].handle, 'priya');
498
+ assert.equal(list.body.count, list.body.connections.length);
499
+ assert.equal((await call(s.origin, 'DELETE', '/connections/51')).body.outcome, 'removed');
500
+ assert.equal((await call(s.origin, 'DELETE', '/connections/61')).body.outcome, 'cancelled');
501
+ assert.equal((await call(s.origin, 'DELETE', '/connections/abc')).status, 404);
502
+ } finally { s.stop(); }
503
+ const inactive = await stub({ FIXTURE_ALIAS: JSON.stringify({ connections: 'connections-inactive' }) });
504
+ try {
505
+ assert.equal((await call(inactive.origin, 'GET', '/connections')).body.error.code, 'profile_provisional');
506
+ } finally { inactive.stop(); }
507
+ });
@@ -183,7 +183,7 @@ test('the file builds nodes only and makes only its two reads', () => {
183
183
  // ── the pages ────────────────────────────────────────────────────────────────
184
184
 
185
185
  test('every page that wears the hub bar loads it once, before the Artist review tag', () => {
186
- for (const page of ['projects', 'account', 'invitations', 'guild', 'privacy', 'terms', 'contact']) {
186
+ for (const page of ['projects', 'account', 'invitations', 'connections', 'guild', 'privacy', 'terms', 'contact']) {
187
187
  const html = fs.readFileSync(path.join(LANDING, `${page}.html`), 'utf8');
188
188
  const tags = html.match(/<script src="\/assets\/invite-badge\.js"><\/script>/g) || [];
189
189
  assert.equal(tags.length, 1, `${page}: one tag`);
@@ -368,5 +368,23 @@ t('a handle outside the canonical charset rides as null — the identity still s
368
368
  }
369
369
  });
370
370
 
371
+ t('a vouch whose subject is not a numeric GitHub id is no vouch — the id feeds the hub profile read', () => {
372
+ // The hub mints only for numeric ids, so this shape needs the raw signer: a
373
+ // correctly signed vouch whose sub is anything else still lands unvouched.
374
+ for (const sub of ['octo', '42abc', '-42', '4.2', ' 42', '']) {
375
+ const token = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub, applicant_login: 'octo', handle: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: `sub-${sub}` });
376
+ assert.equal(applicationVouchIdentity(token, vouchOpts('octo')), null, `sub=${JSON.stringify(sub)}`);
377
+ }
378
+ const numeric = signRaw({ iss: 'cloudbongos-hub', aud: 'client-x', sub: 42, applicant_login: 'octo', handle: 'octo', purpose: 'application', iat: nowS(), exp: nowS() + 60, jti: 'sub-number' });
379
+ assert.deepEqual(applicationVouchIdentity(numeric, vouchOpts('octo')), { githubId: '42', handle: 'octo' }, 'a JSON number is still a numeric id');
380
+ });
381
+
382
+ t('a refused vouch logs at debug — the public POST /access-requests lets anyone trigger it', () => {
383
+ const src = require('node:fs').readFileSync(new URL('../src/bongos/auth-idp.js', import.meta.url), 'utf8');
384
+ const body = src.slice(src.indexOf('function applicationVouchIdentity('), src.indexOf('function readApplicationVouch('));
385
+ assert.match(body, /log\.debug\(\{ reason: err\.message \}, 'application vouch not accepted/);
386
+ assert.doesNotMatch(body, /log\.(info|warn|error)\(/, 'no louder line on the refusal path');
387
+ });
388
+
371
389
  console.log(`\nidp_device_exchange: ${passed} passed, ${failed} failed`);
372
390
  if (failed > 0) process.exit(1);
@@ -171,8 +171,8 @@ for (const p of PAGES) {
171
171
  '<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
172
172
  '<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
173
173
  '<div class="bandIn bandProfile">',
174
- // Invitations and Account are one apex-only region: both pages exist only on the hub (task 1004276)
175
- '<!--apex-only:start--><li class="bandCell"><a class="bandT" href="/invitations">Invitations</a></li><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->',
174
+ // Invitations, Connections and Account are one apex-only region: the three pages exist only on the hub (tasks 1004276, 1004278)
175
+ '<!--apex-only:start--><li class="bandCell"><a class="bandT" href="/invitations">Invitations</a></li><li class="bandCell"><a class="bandT" href="/connections">Connections</a></li><li class="bandCell"><a class="bandT" href="/account">Account</a></li><!--apex-only:end-->',
176
176
  '<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
177
177
  ]) {
178
178
  assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
@@ -188,12 +188,14 @@ for (const p of PAGES) {
188
188
  const html = read(p.file);
189
189
  const clauses = (band) => [...band.matchAll(/>([^<]+)<\/a><span class="bandC">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
190
190
  assert.deepEqual(clauses(bandOf(html, 'menuProjects')), clauses(bandOf(HUB, 'menuProjects')), 'the Projects band’s items and clauses are the hub’s');
191
+ assert.deepEqual(clauses(bandOf(html, 'menuProjects'))[2], ['Your projects', 'the ones you own'],
192
+ 'the mine view lists what you own; the projects you build on live on /account (task 1004590)');
191
193
  assert.deepEqual(bandLinks(bandOf(html, 'menuProjects')), [
192
194
  ['Sky', '/projects?view=map'], ['Table', '/projects?view=map&amp;sub=table'],
193
195
  ['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
194
196
  ], 'this page has no views of its own, so every item names /projects in full');
195
197
  assert.deepEqual(bandLinks(bandOf(html, 'menuProfile')), [
196
- ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
198
+ ['My projects', '/projects?view=mine'], ['Invitations', '/invitations'], ['Connections', '/connections'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
197
199
  ]);
198
200
  assert.doesNotMatch(chrome(html), /aria-current/, 'no band item is this page');
199
201
  const child = stripApexOnly(html, false);
@@ -213,7 +215,7 @@ for (const p of PAGES) {
213
215
  });
214
216
  }
215
217
 
216
- test('the three doors, the account page and the invitations page carry one bar: identical but for the sign-in return and the current place', () => {
218
+ test('the three doors, the account page, the invitations page and the connections page carry one bar: identical but for the sign-in return and the current place', () => {
217
219
  const norm = (html, route) => chrome(html)
218
220
  .replace(`return=${route}&amp;`, 'return=ROUTE&amp;')
219
221
  .replace(/href="#\w+">Skip to content/, 'href="#MAIN">Skip to content')
@@ -225,6 +227,7 @@ test('the three doors, the account page and the invitations page carry one bar:
225
227
  assert.equal(doors[2], doors[0], 'contact.html and privacy.html');
226
228
  assert.equal(norm(ACCOUNT, '/account'), doors[0], 'account.html and the doors: one bar, so a change to it is a change to all five');
227
229
  assert.equal(norm(read('invitations.html'), '/invitations'), doors[0], 'invitations.html too (task 1004276)');
230
+ assert.equal(norm(read('connections.html'), '/connections'), doors[0], 'connections.html too (task 1004278)');
228
231
  });
229
232
 
230
233
  test('cosmos.css still carries the bar and the bands the doors lean on', () => {
@@ -346,7 +349,7 @@ for (const p of PAGES) {
346
349
  assert.equal(dom.els.get('who').getAttribute('aria-label'), 'Account, octocat');
347
350
  for (const id of ['whoImg', 'bandWhoImg']) assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
348
351
  assert.equal(dom.els.get('mineCell').hidden, false);
349
- assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Invitations', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
352
+ assert.deepEqual(dom.band('menuProfile').items.map((i) => i.text), hub ? ['My projects', 'Invitations', 'Connections', 'Account', 'Sign out'] : ['My projects', 'Sign out']);
350
353
  });
351
354
  }
352
355