@bongos/core 1.24.24 → 1.24.26

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 (65) hide show
  1. package/.bongos-core.json +84 -69
  2. package/docs/copy-inventory.md +217 -212
  3. package/docs/copy-registry.json +298 -233
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +855 -852
  6. package/modules/hall-ui/public/ai-studio.html +1 -1
  7. package/modules/hall-ui/public/blockers.html +1 -1
  8. package/modules/hall-ui/public/blockers.js +3 -1
  9. package/modules/hall-ui/public/board-room.html +2 -0
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/collab.js +4 -0
  12. package/modules/hall-ui/public/commands.html +2 -2
  13. package/modules/hall-ui/public/copy-desk.html +2 -2
  14. package/modules/hall-ui/public/criterion-uat-ui.js +1 -1
  15. package/modules/hall-ui/public/deploy.html +2 -0
  16. package/modules/hall-ui/public/drachmae.html +1 -1
  17. package/modules/hall-ui/public/fleet.html +2 -0
  18. package/modules/hall-ui/public/gate.html +2 -0
  19. package/modules/hall-ui/public/goals-page.js +1 -1
  20. package/modules/hall-ui/public/goals.css +3 -2
  21. package/modules/hall-ui/public/goals.html +2 -2
  22. package/modules/hall-ui/public/goals.js +1 -1
  23. package/modules/hall-ui/public/government.html +2 -0
  24. package/modules/hall-ui/public/hall-widgets.js +9 -9
  25. package/modules/hall-ui/public/ideas.html +2 -2
  26. package/modules/hall-ui/public/index.html +2 -0
  27. package/modules/hall-ui/public/lightbulb.js +1 -1
  28. package/modules/hall-ui/public/market-watch-item.html +1 -1
  29. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  30. package/modules/hall-ui/public/market-watch-settings.html +2 -0
  31. package/modules/hall-ui/public/market-watch.html +1 -1
  32. package/modules/hall-ui/public/modules.html +1 -1
  33. package/modules/hall-ui/public/my-agents.html +2 -2
  34. package/modules/hall-ui/public/primer.html +1 -1
  35. package/modules/hall-ui/public/profile.html +5 -5
  36. package/modules/hall-ui/public/project-settings.html +2 -0
  37. package/modules/hall-ui/public/ranks.html +3 -3
  38. package/modules/hall-ui/public/roadmap.html +1 -1
  39. package/modules/hall-ui/public/roster.html +1 -1
  40. package/modules/hall-ui/public/sessions.html +2 -0
  41. package/modules/hall-ui/public/settings-software-update.js +1 -1
  42. package/modules/hall-ui/public/settings-specialities.js +1 -1
  43. package/modules/hall-ui/public/settings.html +3 -1
  44. package/modules/hall-ui/public/settings.js +3 -0
  45. package/modules/hall-ui/public/studio.js +3 -3
  46. package/modules/hall-ui/public/style.css +90 -1
  47. package/modules/hall-ui/public/task-detail.js +2 -2
  48. package/modules/hall-ui/public/tweak-editor.html +1 -1
  49. package/modules/hall-ui/public/watch.html +2 -0
  50. package/modules/hall-ui/public/watch.js +1 -1
  51. package/modules/hall-ui/public/work.html +3 -3
  52. package/modules/hall-ui/records/loader.md +35 -0
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +16 -0
  56. package/scripts/hall-preview/README.md +1 -0
  57. package/scripts/hall-preview/server.js +9 -0
  58. package/src/bongos/apex-hall-redirects.js +53 -0
  59. package/src/bongos/package-registry.js +3 -2
  60. package/src/bongos/routes/core-update.js +11 -3
  61. package/src/bongos/serve-internal.js +3 -18
  62. package/src/bongos/software-update.js +36 -16
  63. package/src/module-api.js +1 -1
  64. package/tests/hall_loading.mjs +153 -0
  65. package/tests/software_update.mjs +94 -3
@@ -1186,6 +1186,9 @@
1186
1186
  function hideLayout(hide) {
1187
1187
  const layout = document.getElementById('settings-layout');
1188
1188
  if (layout) layout.hidden = !!hide;
1189
+ // Every path past /me decides the layout here, so the page loader stops here too (task 1005043).
1190
+ const loading = document.getElementById('settings-loading');
1191
+ if (loading) loading.hidden = true;
1189
1192
  }
1190
1193
 
1191
1194
  // ---- bootstrap -----------------------------------------------------------
@@ -755,7 +755,7 @@
755
755
 
756
756
  function statusHtml() {
757
757
  if (!home.pages) {
758
- return `<p class="st-panel__line">${home.pagesFailed ? 'The pages could not be read just now. Try again in a moment.' : 'Reading the pages…'}</p>`;
758
+ return home.pagesFailed ? '<p class="st-panel__line">The pages could not be read just now. Try again in a moment.</p>' : '<p class="hall-loading" role="status">Reading the pages…</p>';
759
759
  }
760
760
  const rows = lib.surfaceRows(home.pages, lib.landingHost(byId('hall-main') && byId('hall-main').getAttribute('data-apex'), window.location && window.location.host)).map((s) => `
761
761
  <li class="st-surface">
@@ -804,7 +804,7 @@
804
804
 
805
805
  function siteHtml() {
806
806
  if (!home.site) {
807
- return `<p class="st-panel__line">${home.siteFailed ? SITE_FAILED : 'Reading the reviews…'}</p>`;
807
+ return home.siteFailed ? `<p class="st-panel__line">${SITE_FAILED}</p>` : '<p class="hall-loading" role="status">Reading the reviews…</p>';
808
808
  }
809
809
  if (!home.site.length) return `<p class="st-panel__line">${SITE_NONE}</p>`;
810
810
  const rows = home.site.map((r) => `
@@ -1178,7 +1178,7 @@
1178
1178
  }
1179
1179
 
1180
1180
  function siteListPage() {
1181
- if (!home.site) return `<p class="st-panel__line">${home.siteFailed ? SITE_FAILED : 'Reading the reviews…'}</p>`;
1181
+ if (!home.site) return home.siteFailed ? `<p class="st-panel__line">${SITE_FAILED}</p>` : '<p class="hall-loading" role="status">Reading the reviews…</p>';
1182
1182
  if (!home.site.length) return `<p class="st-panel__line">${SITE_NONE}</p>`;
1183
1183
  return `<ul class="st-doc-list">${home.site.map((r) => `<li><a class="st-doc-list__row" href="${escapeHtml(siteHref(r.link))}">
1184
1184
  <span class="st-doc-list__page">${escapeHtml(r.page_title || r.page_path || r.page_id)}</span>
@@ -4317,7 +4317,8 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
4317
4317
  .empty-state__action { display: inline-block; margin-top: 6px; font-size: 13px; }
4318
4318
 
4319
4319
  /* skeleton shimmer — standard-height placeholder rows while data loads, so
4320
- cards don't jump; derives from the ink/bg tokens like everything else */
4320
+ cards don't jump; derives from the ink/bg tokens like everything else. It
4321
+ breathes in the loader's beat (task 1005043; the keyframes sit with it). */
4321
4322
  .kit-skel {
4322
4323
  display: inline-block;
4323
4324
  width: 60%;
@@ -4325,9 +4326,97 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
4325
4326
  height: 12px;
4326
4327
  border-radius: 2px;
4327
4328
  background: var(--skeleton);
4329
+ animation: hall-loading-breathe .8s ease-in-out infinite alternate-reverse;
4328
4330
  }
4329
4331
  .kit-skel-row td { border-bottom: 1px solid var(--rule-soft); }
4330
4332
 
4333
+ /* ---- the loader (task 1005043) ----------------------------------------------
4334
+ One loader for everything a hall page waits on, drawn from the Bongos mark
4335
+ (shell.js's brand-mark SVG: the ring, the core and the accent dot). A page
4336
+ opts in by giving its existing placeholder the class; its own words stay as
4337
+ the caption, so no page gains markup. The ring and the dot turn together round
4338
+ the still core and the ink breathes, after the Vibe skeleton the owner pointed
4339
+ at (vibe.monday.com: 0.4 to 1 and back, 0.8s). Three sizes: the default for a
4340
+ panel, --page for a page's main area, --inline for a line in a list or card.
4341
+
4342
+ DESIGN.md holds it: CSS only, transform and filter only (the breath and the
4343
+ arrival are filter: opacity(), not opacity), every colour a var() or a mix of
4344
+ one; the black in the two masks is alpha, not paint. The 20s floor is for the
4345
+ ambient plates: a loader is the live thing on the page, so it keeps a loader's
4346
+ tempo and the dot spends the accent. Each turn eases out of and back into the
4347
+ logo's own pose, so the mark lands on the logo once per beat. It arrives after
4348
+ 240ms, so a read that answers faster never shows it. Under the Kill Switch
4349
+ nothing moves and the rest frame is the logo itself. ---- */
4350
+ .hall-loading {
4351
+ --hl-size: 40px;
4352
+ --hl-pad: 28px;
4353
+ --hl-beat: 1.6s;
4354
+ position: relative;
4355
+ display: flex;
4356
+ flex-direction: column;
4357
+ align-items: center;
4358
+ gap: 12px;
4359
+ margin: 0;
4360
+ padding: var(--hl-pad) 0;
4361
+ font-size: 13.5px;
4362
+ color: var(--ink-faint);
4363
+ text-align: center;
4364
+ animation: hall-loading-arrive 500ms cubic-bezier(.16, 1, .3, 1) 240ms both;
4365
+ }
4366
+ .hall-loading::before,
4367
+ .hall-loading::after {
4368
+ content: "";
4369
+ display: block;
4370
+ flex: none;
4371
+ width: var(--hl-size);
4372
+ height: var(--hl-size);
4373
+ -webkit-mask: var(--hl-shape) center / contain no-repeat;
4374
+ mask: var(--hl-shape) center / contain no-repeat;
4375
+ animation: hall-loading-turn var(--hl-beat) cubic-bezier(.45, .05, .55, .95) infinite;
4376
+ }
4377
+ /* the ring and the core: one ink, breathing */
4378
+ .hall-loading::before {
4379
+ --hl-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cpath d='M137 31 A78 78 0 1 0 170 66' fill='none' stroke='black' stroke-width='15' stroke-linecap='round'/%3E%3Ccircle cx='100' cy='100' r='55'/%3E%3C/svg%3E");
4380
+ background: color-mix(in srgb, var(--ink) 30%, var(--bg-card));
4381
+ animation:
4382
+ hall-loading-turn var(--hl-beat) cubic-bezier(.45, .05, .55, .95) infinite,
4383
+ hall-loading-breathe calc(var(--hl-beat) / 2) ease-in-out infinite alternate-reverse;
4384
+ }
4385
+ /* the dot: the accent, laid exactly over the mark and turning with it */
4386
+ .hall-loading::after {
4387
+ --hl-shape: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Ccircle cx='156' cy='46' r='12'/%3E%3C/svg%3E");
4388
+ position: absolute;
4389
+ top: var(--hl-pad);
4390
+ left: calc(50% - var(--hl-size) / 2);
4391
+ background: var(--accent);
4392
+ }
4393
+ .hall-loading--page { --hl-size: 64px; --hl-pad: 72px; gap: 16px; }
4394
+ .hall-loading--inline {
4395
+ --hl-size: 16px;
4396
+ --hl-pad: 0px;
4397
+ display: inline-flex;
4398
+ flex-direction: row;
4399
+ gap: 8px;
4400
+ text-align: left;
4401
+ }
4402
+ .hall-loading--inline::after { top: calc(50% - var(--hl-size) / 2); left: 0; }
4403
+ /* --wait: a page whose every section stays [hidden] until it knows who is
4404
+ reading (the sealed/body pairs) puts one loader FIRST in its <main>. It shows
4405
+ only while nothing after it is showing, and steps aside the moment the page
4406
+ reveals anything (the body, the sealed notice, an error), so no page script
4407
+ has to remember to hide it. Without :has() it never shows: today's page. */
4408
+ /* --zones: the same, for a view made of always-present, empty zones that cards
4409
+ mount into (Home, Watch's panes): it shows while no showing zone after it
4410
+ holds a showing child. */
4411
+ .hall-loading--wait, .hall-loading--zones { display: none; }
4412
+ @supports selector(:has(a)) {
4413
+ .hall-loading--wait:not(:has(~ :not([hidden], script, template, style))) { display: flex; }
4414
+ .hall-loading--zones:not(:has(~ :not([hidden]) > :not([hidden], script, template, style))) { display: flex; }
4415
+ }
4416
+ @keyframes hall-loading-arrive { from { filter: opacity(0); transform: translateY(6px); } }
4417
+ @keyframes hall-loading-turn { to { transform: rotate(1turn); } }
4418
+ @keyframes hall-loading-breathe { from { filter: opacity(.45); } }
4419
+
4331
4420
  /* collapsible card body — the Work-backlog <details> idiom, generalized */
4332
4421
  .kit-collapse__summary {
4333
4422
  cursor: pointer;
@@ -413,7 +413,7 @@
413
413
  }
414
414
 
415
415
  async function renderPromote() {
416
- box.innerHTML = '<p class="deeplink-card__note">Loading versions…</p>';
416
+ box.innerHTML = '<p class="hall-loading hall-loading--inline" role="status">Loading versions…</p>';
417
417
  // Both dead ends offer the way back through the same delegated [data-go]
418
418
  // handler the forms use — a builder must never be stranded mid-panel.
419
419
  function bail(message) {
@@ -658,7 +658,7 @@
658
658
  function bodyFor(state) {
659
659
  var nodes = [];
660
660
  var label = '#' + state.id;
661
- if (state.loading) { nodes.push(h('p', 'deeplink-card__title', 'Loading ' + label + '…')); return nodes; }
661
+ if (state.loading) { nodes.push(h('p', 'hall-loading', 'Loading ' + label + '…')); return nodes; }
662
662
  if (state.needsAuth) {
663
663
  nodes.push(h('p', 'deeplink-card__title', label));
664
664
  nodes.push(h('p', 'deeplink-card__note', 'Sign in to the Builders’ Hall to view this record.'));
@@ -74,7 +74,7 @@
74
74
  <p class="tw-hint" id="tw-hint" hidden></p>
75
75
  <div class="tw-notice" id="tw-notice" role="status" hidden></div>
76
76
  <div id="tw-doc">
77
- <p class="tw-loading">Reading the page…</p>
77
+ <p class="hall-loading hall-loading--page" role="status">Reading the page…</p>
78
78
  </div>
79
79
  </article>
80
80
 
@@ -34,6 +34,8 @@
34
34
  </div>
35
35
 
36
36
  <main id="hall-main">
37
+ <!-- task 1005043: the page loader, shown until the sealed notice or a pane has something showing. -->
38
+ <p class="hall-loading hall-loading--page hall-loading--zones" role="status">Loading…</p>
37
39
  <!-- Restricted notice — shown by watch.js when the viewer is below the
38
40
  floor, with the floor written in from the pack's rank label. -->
39
41
  <section class="scroll" id="sealed-scroll" aria-labelledby="sealed-h" hidden>
@@ -754,7 +754,7 @@
754
754
  if (!btn || !target.contains(btn)) return;
755
755
  const slug = btn.getAttribute('data-doc-open');
756
756
  const view = $('#engagement-doc-view');
757
- if (view) view.innerHTML = foot('Reading…');
757
+ if (view) view.innerHTML = '<p class="hall-loading" role="status">Reading…</p>';
758
758
  try {
759
759
  const res = await fetchJson(`/security/docs/${encodeURIComponent(slug)}`);
760
760
  const doc = res.doc || {};
@@ -105,7 +105,7 @@
105
105
  <p class="claim-count-line" id="claim-count" aria-live="polite"></p>
106
106
 
107
107
  <div class="task-list" id="task-list">
108
- <div class="profile__placeholder">…</div>
108
+ <div class="hall-loading" role="status">Loading…</div>
109
109
  </div>
110
110
  <div id="ready-pager-mount"></div>
111
111
  </section>
@@ -117,7 +117,7 @@
117
117
 
118
118
  <table class="ledger" id="wip-table">
119
119
  <thead><tr><th></th><th>#</th><th>Task</th><th>Builder</th><th>For</th><th></th></tr></thead>
120
- <tbody><tr><td colspan="6" class="ledger__empty">…</td></tr></tbody>
120
+ <tbody><tr><td colspan="6" class="ledger__empty"><span class="hall-loading" role="status">Loading…</span></td></tr></tbody>
121
121
  </table>
122
122
  <div id="wip-pager-mount"></div>
123
123
  </section>
@@ -139,7 +139,7 @@
139
139
  <div class="stat-row" id="works-summary" style="margin-bottom: 14px" hidden></div>
140
140
  <p class="scroll__lede">The most recently shipped tasks.</p>
141
141
  <div class="history" id="history-list">
142
- <div class="profile__placeholder">…</div>
142
+ <div class="hall-loading" role="status">Loading…</div>
143
143
  </div>
144
144
  <div id="done-pager-mount"></div>
145
145
  </section>
@@ -0,0 +1,35 @@
1
+ # The loader — the Bongos mark wherever a hall page waits for its data (task 1005043)
2
+
3
+ Goal 1000095. Owner ask, 2026-11-03: a smooth load screen with the Bongos logo whenever the hall
4
+ waits for data, in the spirit of monday.com's Vibe data loaders (vibe.monday.com, Skeleton: grey
5
+ shapes breathing 0.4 to 1 and back over 0.8s, in the place the content will land).
6
+
7
+ - **One component, in `style.css`: `.hall-loading`.** CSS only. `::before` is the ring and the core
8
+ (a mask of shell.js's brand-mark geometry, painted `--ink` at 30%), `::after` is the dot (the same
9
+ box, painted `--accent`). Both turn together round the still core, easing out of and back into the
10
+ logo's own pose every 1.6s; the ink breathes on half that beat. The element's own text is the
11
+ caption. It arrives 240ms late (filter: opacity), so a read that answers fast never shows it.
12
+ Sizes: the default (40px, a panel), `--page` (64px, a page's main area), `--inline` (16px, a line).
13
+ - **A page opts in by putting the class on the placeholder it already had**, and gives it
14
+ `role="status"`. The words did not change; a bare `…` became `Loading…`. A placeholder a script
15
+ rewrites IN PLACE (atlas's caption, sessions' status line, the goal dependency ledes, the profile's
16
+ activity placeholder) keeps its plain words, because the class would stay on the finished sentence.
17
+ - **Pages that are blank while they wait get a page loader that puts itself away.** The sealed/body
18
+ pages (board-room, deploy, fleet, gate, government, market-watch-settings, project-settings,
19
+ sessions) open their `<main>` on `.hall-loading--wait`, which shows only while every section after
20
+ it is `[hidden]`. Home and Watch, whose zones and panes are always present, use
21
+ `.hall-loading--zones`: it shows while no showing zone has a showing child. Both are `:has()`
22
+ rules inside `@supports`, so a browser without it simply never shows them. No page script hides
23
+ them. Settings' layout is never `[hidden]`, so `hideLayout()` puts its loader away.
24
+ - **Lists that paint later carry a loader inside them**: Blockers' list (its first `renderList`
25
+ replaces it), Collab's three ask lists (its failure path now stops them and says why, where the page
26
+ used to stay blank), the table placeholder rows (the loader sits inside the `ledger__empty` cell).
27
+ - **The skeletons breathe too.** `.kit-skel` and the goal list's `.gr-skel__ring` take the loader's
28
+ breath, so the Goals list's row skeleton is the Vibe pattern exactly.
29
+ - **DESIGN.md holds.** Transform and filter only; every colour a token (the black in the masks is
30
+ alpha); the Kill Switch stops it all and leaves the logo. The 20s ambient floor is for the plates:
31
+ a loader is the live thing on the page, which is also why its dot may spend the accent.
32
+ - **Not covered:** the apex pages (account, projects, community) and the status page run their own
33
+ sheets; the city, street, goal map and thinking sky draw their own worlds and kept their own lines.
34
+ - **Look at it:** `node scripts/hall-preview/server.js --port 4630 --fixture-me --latency 5000` holds
35
+ every read 5s (task 1005043 added `--latency`). Tests: `tests/hall_loading.mjs`.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.24",
3
+ "version": "1.24.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.24",
9
+ "version": "1.24.26",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.24",
3
+ "version": "1.24.26",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11065,5 +11065,21 @@
11065
11065
  "id": "1004908",
11066
11066
  "text": "Contractors are now only paid once a signed NDA is on file and the payment processor has collected their tax details; if either is missing the payment waits and the person is told why. Bongos never stores tax numbers."
11067
11067
  }
11068
+ ],
11069
+ "1.24.25": [
11070
+ {
11071
+ "id": "1004593",
11072
+ "text": "A hosted project's Settings page no longer offers an unreleased version; it now offers exactly what the Upgrade page offers."
11073
+ }
11074
+ ],
11075
+ "1.24.26": [
11076
+ {
11077
+ "id": "1005043",
11078
+ "text": "While a page in the builders' hall is waiting for its data, the Bongos logo now turns gently where the content will appear, instead of a blank space or a line of grey text."
11079
+ },
11080
+ {
11081
+ "id": "1004736",
11082
+ "text": "A hosted project's Update button no longer leads to a missing page; it opens the Upgrade page for that project."
11083
+ }
11068
11084
  ]
11069
11085
  }
@@ -22,6 +22,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
22
22
  | `--modules-off discord,provisioning` | report those modules disabled in `window.__MODULES__` — verifies `moduleOn()` gating (the Discord links, Instances/Start-a-project widgets must vanish) |
23
23
  | `--empty` | drain every array in every fixture — the empty-state screenshot pass |
24
24
  | `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
25
+ | `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
25
26
 
26
27
  Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
27
28
  the canned writes in `server.js` and, since task 1004685, the five per-builder pref
@@ -65,6 +65,10 @@ const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.tri
65
65
  // own account live. The screenshot matrix in the README wants a stable subject,
66
66
  // and it is the explicit way to preview the signed-out fallback (task 1003315).
67
67
  const FIXTURE_ME = process.argv.includes('--fixture-me');
68
+ // --latency: hold every data read this many ms before answering (task 1005043),
69
+ // so a page's loading state stays on screen long enough to be looked at. The
70
+ // fixtures answer in a millisecond, which is why no loader had ever been seen.
71
+ const LATENCY = Math.max(0, Number(arg('--latency')) || 0);
68
72
  const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
69
73
  process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
70
74
 
@@ -295,6 +299,11 @@ function fixtureFor(apiPath, query = {}) {
295
299
  // ---- app ----------------------------------------------------------------------
296
300
  const app = express();
297
301
 
302
+ // --latency (above): the live channel is left alone, it is a stream, not a read.
303
+ if (LATENCY) {
304
+ app.use((req, res, next) => (/^\/api\//.test(req.path) && !/\/live\/?$/.test(req.path) ? setTimeout(next, LATENCY) : next()));
305
+ }
306
+
298
307
  // The API stub — intercepts every /api/gds/*, /api/bongos/*, /api/bongos/v1/*
299
308
  // request BEFORE mountInternalSurfaces can reach the real routes (which would
300
309
  // touch Postgres — the harness is DB-less by design). A plain function
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/apex-hall-redirects.js — the apex hands the hall's addresses to the hall's own
4
+ // host: /builders/* (the ADR 0036 cutover) and /deploy, the deploy door every hosted
5
+ // project's Update button names (task 1004736).
6
+ //
7
+ // Moved out of serve-internal.js, which sits at the 1500-line size ratchet, and mounted from
8
+ // it at the same point in the chain. `skip(req)` is true for a module surface host and for
9
+ // the hall's own host (on a single-host instance that is every request), so both answer only
10
+ // on the apex. `hostOf(req)` is the request's host, lower-cased, without a port.
11
+
12
+ // #739 (ADR 0036 cutover): the builder surface's canonical home is the subdomain. On the
13
+ // apex, /builders and /builders/* go to builders.<apex>/<rest> so old links keep working.
14
+ // 301 (permanent) — the cutover is proven live (#741; started as a 302 in #739). The URL
15
+ // fragment (#/task/N deep-links) is preserved by the browser across the redirect; the query
16
+ // string is carried explicitly. The target apex comes from the request host, so it stays
17
+ // brand-agnostic.
18
+ function buildersRedirect({ skip, hostOf }) {
19
+ return (req, res, next) => {
20
+ if (skip(req)) return next();
21
+ if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
22
+ const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
23
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
24
+ return res.redirect(301, `https://builders.${hostOf(req)}/${rest}${qs}`);
25
+ };
26
+ }
27
+
28
+ // THE DEPLOY DOOR ON THE APEX (task 1004736). A hosted project's Update button is
29
+ // `<hub origin>/deploy?project=<slug>` (routes/core-update.js hubDeployUrl, and the
30
+ // HUB_UPGRADE_URL provisioning writes), and the hub origin is its apex, where nothing served
31
+ // /deploy: every hosted owner who pressed Update met a 404. The page is the hall's, so the
32
+ // apex hands the request over, query and all, and the hall decides (its page gate, or
33
+ // Settings where no door runs). Answered here rather than by rewriting the links, because
34
+ // those already sit in every project's web.env and core and would wait for each project's
35
+ // core to move. 302: the apex may serve its own /deploy one day.
36
+ const APEX_DEPLOY_RE = /^\/deploy(?:\.html)?\/?$/;
37
+ function deployRedirect({ skip, hostOf }) {
38
+ return (req, res, next) => {
39
+ if (skip(req)) return next();
40
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
41
+ if (!APEX_DEPLOY_RE.test(req.path)) return next();
42
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
43
+ return res.redirect(302, `https://builders.${hostOf(req)}/deploy${qs}`);
44
+ };
45
+ }
46
+
47
+ /** Mount both on `app`, in order, at the caller's point in the chain. */
48
+ function mountApexHallRedirects(app, { skip, hostOf }) {
49
+ app.use(buildersRedirect({ skip, hostOf }));
50
+ app.use(deployRedirect({ skip, hostOf }));
51
+ }
52
+
53
+ module.exports = { mountApexHallRedirects };
@@ -255,7 +255,8 @@ async function cached(slot, fresh, load, t) {
255
255
  * registry — `{ ok, versions, published, latest, newest }` or `{ ok:false, error }`
256
256
  * notes — the newest version's release notes, `{ ok, version, notes }` or
257
257
  * `{ ok:false, version, error }`; `{ ok:false, skipped }` when the caller
258
- * already holds notes that reach the newest version (`haveNotesFor`).
258
+ * already holds notes that reach the newest version, or needs none
259
+ * (`haveNotesFor(newest, registry)`).
259
260
  *
260
261
  * `get` swaps the HTTPS transport and `store`/`now` the cache, so tests drive the real
261
262
  * parsing and failure paths rather than a stub standing in front of them.
@@ -267,7 +268,7 @@ async function readRegistry({ pkg, haveNotesFor = () => false, get = https.get,
267
268
  () => fetchPackument({ pkg, get, origin }), t);
268
269
  if (!registry.ok) return { registry, notes: { ok: false, error: 'the registry could not be read' } };
269
270
  const version = registry.newest;
270
- if (haveNotesFor(version)) return { registry, notes: { ok: false, skipped: true, version } };
271
+ if (haveNotesFor(version, registry)) return { registry, notes: { ok: false, skipped: true, version } };
271
272
  const ns = store.notes;
272
273
  if (ns.version !== version) { ns.version = version; ns.value = null; ns.at = 0; }
273
274
  const notes = await cached(ns, (s) => s.value.ok || t - s.at < RETRY_AFTER_MS,
@@ -31,6 +31,7 @@ const { asyncHandler } = require('./_helpers');
31
31
  const auth = require('../auth');
32
32
  const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
33
33
  const { readSoftwareUpdate } = require('../software-update');
34
+ const { followForShape } = require('../../../scripts/bongos/update-channel.js');
34
35
  const seams = require('../../module-seams');
35
36
  const { loadIdpConfig } = require('../auth-config');
36
37
  const upgradeRequest = require('../upgrade-request');
@@ -88,6 +89,13 @@ function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
88
89
  return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
89
90
  }
90
91
 
92
+ // WHICH PUBLISHED VERSIONS THIS HALL MAY BE OFFERED (task 1004593, ADR 0361 D5): the hall that
93
+ // runs the door is the platform's own, the one project that takes every candidate; every other
94
+ // project follows releases, as the runner and /deploy offer them. followForShape is the runner's.
95
+ function followFor(door) {
96
+ return followForShape(door && door.kind === 'local' ? 'control-plane' : null);
97
+ }
98
+
91
99
  function safeHubUpgradeUrl() {
92
100
  try { return require('../../instance-config').resolveEnv('HUB_UPGRADE_URL') || null; } catch { return null; }
93
101
  }
@@ -327,12 +335,12 @@ function buildCoreUpdateRouter({
327
335
  asyncHandler('GET /software-update', async (req, res) => {
328
336
  let running = null;
329
337
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
338
+ const door = updateDoor(doorInputs());
330
339
  // canUpdate does not depend on the rule, so it runs beside the rule read, not after it.
331
340
  const [update, can] = await Promise.all([
332
- resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
341
+ resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel, follow: followFor(door) })), channel_source: source })),
333
342
  canUpdate(req),
334
343
  ]);
335
- const door = updateDoor(doorInputs());
336
344
  const ask = await readAsk({ update, door, deps: askDeps() });
337
345
  const held = await safeHold(upgradeHold, { running, offered: update && update.reading === 'ok' ? update.offered : null });
338
346
  // The history only for a viewer who holds core.pin.move (see safeMoved's gate note).
@@ -357,8 +365,8 @@ function buildCoreUpdateRouter({
357
365
  let running = null;
358
366
  try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
359
367
  const { channel } = await resolveChannel(selfChannel);
360
- const update = await softwareUpdate({ running, channel });
361
368
  const door = updateDoor(doorInputs());
369
+ const update = await softwareUpdate({ running, channel, follow: followFor(door) });
362
370
  const deps = askDeps();
363
371
  return upgradeRequest.requestUpgrade({
364
372
  host: deps.host,
@@ -34,6 +34,7 @@ const fs = require('fs');
34
34
  const express = require('express');
35
35
 
36
36
  const { buildGdsRouter } = require('./routes');
37
+ const { mountApexHallRedirects } = require('./apex-hall-redirects');
37
38
  const { attachFail } = require('./middleware/error-envelope');
38
39
  const metrics = require('./metrics');
39
40
  const { logger } = require('./logger');
@@ -923,24 +924,8 @@ function mountInternalSurfaces(app) {
923
924
  next();
924
925
  });
925
926
 
926
- // #739 (ADR 0036 cutover): the builder surface's canonical home is now the
927
- // subdomain. On the apex/game host, redirect /builders and /builders/* to
928
- // builders.<apex>/<rest> so old links keep working while the subdomain becomes
929
- // the front door. Runs ONLY on the apex (skipped on the status + builders
930
- // hosts; /api is host-agnostic and mounted earlier, and never starts /builders).
931
- // 301 (permanent) — the cutover is proven live + verified end-to-end (#741;
932
- // started as a 302 in #739). The URL fragment (#/task/N deep-links) is preserved
933
- // by the browser across the redirect; the query string is carried explicitly.
934
- // The redirect target apex is read from the request host (strip a leading
935
- // "builders."/"status." if present) so it stays brand-agnostic.
936
- app.use((req, res, next) => {
937
- if (isModuleSurfaceHost(req) || isBuildersHost(req)) return next();
938
- if (req.path !== '/builders' && !req.path.startsWith('/builders/')) return next();
939
- const apex = siteOf(req).host;
940
- const rest = req.path.replace(/^\/builders\/?/, ''); // '/builders'→'', '/builders/watch'→'watch'
941
- const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
942
- return res.redirect(301, `https://builders.${apex}/${rest}${qs}`);
943
- });
927
+ // The apex hands /builders/* (ADR 0036) and /deploy (task 1004736) to the hall's own host.
928
+ mountApexHallRedirects(app, { skip: (req) => isModuleSurfaceHost(req) || isBuildersHost(req), hostOf: (req) => siteOf(req).host });
944
929
 
945
930
  // The /watch monitoring page and the /gate page (task 1179) both sit behind
946
931
  // `page.view.government` — Metic and up since ADR 0157. Same server-side page
@@ -22,6 +22,12 @@
22
22
  // /deploy, via the provisioning.selfUpdateChannel seam — task 1004535), else the runner's
23
23
  // default. The banner (core-update.js) is handed the same rule, so the two cannot disagree.
24
24
  //
25
+ // ONLY WHAT THIS PROJECT'S FOLLOW ADMITS IS OFFERED (task 1004593, ADR 0361 D5). Merges publish
26
+ // as candidates above the registry's `latest` label; a project that follows releases is offered
27
+ // nothing above it, through `versionsForFollow`, the function the runner and the banner use.
28
+ // Before this the panel offered a candidate that /deploy then said did not exist. The route
29
+ // names the follow: candidates only on the platform's own hall.
30
+ //
25
31
  // A READING, NEVER A GUESS. `reading` is 'ok' only when the registry answered; "up to date"
26
32
  // is `offered: null` on an ok reading and nothing else. Notes that could not be read say so,
27
33
  // and a version whose notes are missing is shown as missing rather than as empty.
@@ -30,6 +36,8 @@ const {
30
36
  CORE_PKG,
31
37
  DEFAULT_CHANNEL,
32
38
  normalizeChannel,
39
+ normalizeFollow,
40
+ versionsForFollow,
33
41
  resolveChannelTarget,
34
42
  compareSemver,
35
43
  } = require('../../scripts/bongos/update-channel.js');
@@ -62,22 +70,29 @@ function entriesFor(notes, version) {
62
70
  return out;
63
71
  }
64
72
 
73
+ /** The published versions `follow` admits, oldest first; [] when a release follower has no label. */
74
+ function admittedVersions(follow, registry) {
75
+ return versionsForFollow({ follow, available: registry.versions, released: registry.latest });
76
+ }
77
+
65
78
  /**
66
79
  * The panel's whole answer, from one registry reading. PURE — no clock, no I/O.
67
80
  *
68
81
  * `registry` and `notes` are package-registry.js's `readRegistry` result. Versions in the
69
- * update are the published ones above `running` and at or below `offered`, newest first.
82
+ * update are the admitted ones above `running` and at or below `offered`, newest first, and
83
+ * `newest` is the newest admitted version, so a candidate is never named to a release follower.
70
84
  */
71
- function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt }) {
85
+ function softwareUpdateVerdict({ running, channel, follow, registry, notes, checkedAt }) {
72
86
  const ch = normalizeChannel(channel == null ? DEFAULT_CHANNEL : channel);
73
- const base = { checked_at: checkedAt, running: running || null, channel: ch };
74
- if (!running || !VERSION_RE.test(String(running))) {
75
- return { ...base, reading: 'no_answer', error: 'this project could not name the version it runs', offered: null, newest: null, whats_new: null };
76
- }
77
- if (!registry || !registry.ok) {
78
- return { ...base, reading: 'no_answer', error: (registry && registry.error) || 'the registry could not be read', offered: null, newest: null, whats_new: null };
79
- }
80
- const offered = resolveChannelTarget({ installed: running, channel: ch, available: registry.versions }) || null;
87
+ const fol = normalizeFollow(follow);
88
+ const base = { checked_at: checkedAt, running: running || null, channel: ch, follow: fol };
89
+ const noAnswer = (error) => ({ ...base, reading: 'no_answer', error, offered: null, newest: null, whats_new: null });
90
+ if (!running || !VERSION_RE.test(String(running))) return noAnswer('this project could not name the version it runs');
91
+ if (!registry || !registry.ok) return noAnswer((registry && registry.error) || 'the registry could not be read');
92
+ const admitted = admittedVersions(fol, registry);
93
+ // No label, no answer: the runner moves a release follower on nothing it cannot call released.
94
+ if (!admitted.length) return noAnswer('the registry does not say which version is released');
95
+ const offered = resolveChannelTarget({ installed: running, channel: ch, available: admitted }) || null;
81
96
  const published = registry.published || {};
82
97
  const answer = {
83
98
  ...base,
@@ -85,12 +100,12 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
85
100
  error: null,
86
101
  offered,
87
102
  offered_published_at: offered ? published[offered] || null : null,
88
- newest: registry.newest || null,
103
+ newest: admitted.reduce((a, v) => (compareSemver(v, a) > 0 ? v : a)),
89
104
  whats_new: null,
90
105
  };
91
106
  if (!offered) return answer;
92
107
 
93
- const inUpdate = registry.versions
108
+ const inUpdate = admitted
94
109
  .filter((v) => compareSemver(v, running) > 0 && compareSemver(v, offered) <= 0)
95
110
  .sort((a, b) => compareSemver(b, a));
96
111
  const shown = inUpdate.slice(0, VERSIONS_MAX);
@@ -115,14 +130,19 @@ function softwareUpdateVerdict({ running, channel, registry, notes, checkedAt })
115
130
  * `get`/`now`/`store` pass through to the registry reader, so a test drives the real
116
131
  * packument parsing, the real tarball stream and the real cache rather than a stub.
117
132
  */
118
- async function readSoftwareUpdate({ running, channel, get, now = Date.now, store } = {}) {
133
+ async function readSoftwareUpdate({ running, channel, follow, get, now = Date.now, store } = {}) {
119
134
  const t = now();
135
+ const fol = normalizeFollow(follow);
120
136
  const { registry, notes } = await readRegistry({
121
137
  pkg: CORE_PKG, get, now, store,
122
- // Nothing to describe when nothing newer is published: skip the download.
123
- haveNotesFor: (newest) => !VERSION_RE.test(String(running || '')) || compareSemver(newest, running) <= 0,
138
+ // Nothing to describe when nothing this project could take is newer: skip the download.
139
+ haveNotesFor: (_newest, facts) => {
140
+ if (!VERSION_RE.test(String(running || ''))) return true;
141
+ const admitted = admittedVersions(fol, facts);
142
+ return !admitted.some((v) => compareSemver(v, running) > 0);
143
+ },
124
144
  });
125
- return softwareUpdateVerdict({ running, channel, registry, notes, checkedAt: new Date(t).toISOString() });
145
+ return softwareUpdateVerdict({ running, channel, follow: fol, registry, notes, checkedAt: new Date(t).toISOString() });
126
146
  }
127
147
 
128
148
  module.exports = { readSoftwareUpdate };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.24'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.26'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');