@bongos/core 1.21.70 → 1.21.72

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 (68) hide show
  1. package/.bongos-core.json +123 -63
  2. package/.claude/skills/goal-create/SKILL.md +21 -3
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -3
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +46 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +388 -377
  13. package/docs/copy-registry.json +567 -448
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-readings.json +912 -931
  17. package/docs/recipes/core-release-pipeline.md +6 -0
  18. package/docs/recipes/upgrading-the-core.md +2 -0
  19. package/modules/hall-ui/public/settings-software-update.js +77 -2
  20. package/modules/hall-ui/public/settings.html +1 -1
  21. package/modules/lifecycle/db-tasks.js +12 -0
  22. package/modules/lifecycle/goal-spec-task.js +129 -0
  23. package/modules/lifecycle/routes/goals.js +7 -9
  24. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  25. package/modules/lifecycle/upkeep-home.js +55 -0
  26. package/modules/public-landing/public/projects-hall-live.states.json +7 -1
  27. package/modules/public-landing/public/projects.html +581 -0
  28. package/modules/public-landing/public/projects.probes.json +2 -2
  29. package/modules/public-landing/public/projects.states.json +1 -1
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/release-notes.json +43 -0
  33. package/scripts/gds/claude-materialize.js +125 -31
  34. package/scripts/gds/claude-md-template.js +47 -10
  35. package/scripts/gds/cli-lib.js +4 -4
  36. package/scripts/gds/feedback-send.js +3 -3
  37. package/scripts/gds/paste-token.js +1 -1
  38. package/scripts/gds/provision-net.js +1 -8
  39. package/scripts/gds/provision-units.js +28 -2
  40. package/scripts/gds/reauth.js +1 -1
  41. package/scripts/gds/run-unit-tests.js +3 -0
  42. package/scripts/gds/setup.js +1 -1
  43. package/scripts/gds/start.js +4 -4
  44. package/scripts/gds/surface.js +15 -3
  45. package/scripts/gds/task.js +32 -5
  46. package/scripts/gds/upgrade-operator.js +73 -0
  47. package/scripts/gds/upgrade.js +7 -2
  48. package/scripts/hall-preview/server.js +3 -0
  49. package/src/bongos/routes/core-update.js +98 -4
  50. package/src/bongos/routes/feedback.js +11 -1
  51. package/src/bongos/upgrade-request.js +158 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/claude_materialize.mjs +195 -18
  54. package/tests/cli_allowlist_permissions.mjs +6 -2
  55. package/tests/cli_surface.mjs +2 -2
  56. package/tests/fixtures/page-ledger.mjs +5 -0
  57. package/tests/goal_admission_override.mjs +1 -1
  58. package/tests/goal_category.mjs +1 -1
  59. package/tests/goal_create_spec_entry.mjs +61 -0
  60. package/tests/goal_spec_task.mjs +197 -0
  61. package/tests/goal_spec_task_db.mjs +95 -0
  62. package/tests/projects_hub_cloud_hop.mjs +303 -0
  63. package/tests/provision_settings_apply.mjs +25 -1
  64. package/tests/software_update.mjs +18 -1
  65. package/tests/start_env_guard.mjs +2 -2
  66. package/tests/upgrade_operator.mjs +105 -0
  67. package/tests/upgrade_request.mjs +348 -0
  68. package/tests/upkeep_home.mjs +156 -0
@@ -615,7 +615,7 @@ function instanceNotRunningCard({ origin, depsMissing } = {}) {
615
615
  out.push(`2. If that doesn't load either, the instance itself is down: \`curl -sI ${origin}/healthz\` says whether it's answering.`);
616
616
  out.push(`3. Once it answers, re-run ${surface.code(surface.action('start'))}.`);
617
617
  out.push('');
618
- out.push(`${surface.strong(`Don't run ${surface.code('bongos dev')} for this.`)} It stands up a separate local instance with its own empty database — it cannot show you this board, and it would hide the outage behind an empty one.`);
618
+ out.push(`${surface.strong(`Don't run ${surface.code(surface.cli('dev'))} for this.`)} It stands up a separate local instance with its own empty database — it cannot show you this board, and it would hide the outage behind an empty one.`);
619
619
  if (depsMissing) {
620
620
  out.push('');
621
621
  out.push(`(The ${surface.code('bongos')} CLI isn't installed here. It's optional — the website above is the same board. ${surface.code('@bongos/core')} is ${surface.strong('public')} on npm, so ${surface.code('npm install @bongos/core')} needs no token or login; a failure here is a real error, not the expected tokenless 404 it used to be.)`);
@@ -629,10 +629,10 @@ function instanceNotRunningCard({ origin, depsMissing } = {}) {
629
629
  if (depsMissing) {
630
630
  out.push(`${n++}. \`npm install\` — installs the toolchain first (the \`bongos\` CLI ships inside \`@bongos/core\`, so nothing else runs until this does).`);
631
631
  }
632
- out.push(`${n++}. \`bongos dev\` — one command: starts Postgres, builds the tables, runs the server, and signs you in.`);
632
+ out.push(`${n++}. ${surface.code(surface.cli('dev'))} — one command: starts Postgres, builds the tables, runs the server, and signs you in.`);
633
633
  out.push(`${n++}. Re-run ${surface.code(surface.action('start'))}.`);
634
634
  out.push('');
635
- out.push(`Meant to work on a *different* instance that's already running? Sign in to it with \`bongos login <url>\` first.`);
635
+ out.push(`Meant to work on a *different* instance that's already running? Sign in to it with ${surface.code(surface.cli('login <url>'))} first.`);
636
636
  return out.join('\n');
637
637
  }
638
638
  function emitInstanceNotRunningCard(opts) {
@@ -980,7 +980,7 @@ async function main() {
980
980
 
981
981
  // ---------- next ----------
982
982
  console.log('');
983
- console.log(`→ ${surface.isTerminal() ? `Claim one with ${surface.code('bongos claim <id>')} in a checkout` : `${surface.strong('Tell me a number')} to claim it`}.`);
983
+ console.log(`→ ${surface.isTerminal() ? `Claim one with ${surface.code(surface.cli('claim <id>'))} in a checkout` : `${surface.strong('Tell me a number')} to claim it`}.`);
984
984
  if (full) {
985
985
  console.log('');
986
986
  console.log('Commands:');
@@ -43,11 +43,23 @@ function taskRef(id, url, env = process.env) {
43
43
  return isTerminal(env) ? `#${id}` : `[#${id}](${url}${id})`;
44
44
  }
45
45
 
46
+ // How a reader runs the CLI (task 1004621). The `bongos` bin ships inside @bongos/core and is not
47
+ // on PATH — in a project it sits at node_modules/.bin/bongos, and the global @cloudbongos/cli that
48
+ // once put a bare `bongos` on PATH is retired (ADR 0324). `npx` finds the local bin with no setup,
49
+ // in a project and in a core checkout alike, so every printed command uses it. A bare `bongos …`
50
+ // sent the first command of a project's session into `command not found`.
51
+ const CLI = 'npx bongos';
52
+
53
+ // A CLI command line to print: `cli('claim <id>')` → `npx bongos claim <id>`.
54
+ function cli(rest) {
55
+ return rest ? `${CLI} ${rest}` : CLI;
56
+ }
57
+
46
58
  // An action the reader can actually run. In a session that is the slash command; in a terminal it
47
- // is the `bongos` verb. `/builder-ship 123` does not exist outside Claude Code.
59
+ // is the `npx bongos` verb. `/builder-ship 123` does not exist outside Claude Code.
48
60
  function action(verb, arg, env = process.env) {
49
61
  const tail = arg == null ? '' : ` ${arg}`;
50
- return isTerminal(env) ? `bongos ${verb}${tail}` : `/builder-${verb}${tail}`;
62
+ return isTerminal(env) ? cli(`${verb}${tail}`) : `/builder-${verb}${tail}`;
51
63
  }
52
64
 
53
65
  // Markdown emphasis is decoration in a feed and literal asterisks on a console.
@@ -86,4 +98,4 @@ function table(headers, rows, env = process.env) {
86
98
  ].join('\n');
87
99
  }
88
100
 
89
- module.exports = { AGENT, TERMINAL, resolveSurface, isTerminal, taskRef, action, strong, em, code, table };
101
+ module.exports = { AGENT, TERMINAL, CLI, cli, resolveSurface, isTerminal, taskRef, action, strong, em, code, table };
@@ -19,10 +19,12 @@
19
19
  // Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
20
20
  // CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
21
21
 
22
- const { cliClient, requireSession, arg, hasFlag } = require('./cli-lib');
22
+ const { cliClient, requireSession, arg, hasFlag, errCode } = require('./cli-lib');
23
23
  // task 1003109: the ship-time VISUAL. The CLI screens the file with the SERVER's
24
24
  // own constants, so what it accepts is exactly what the upload route accepts.
25
25
  const taskVisuals = require('../../modules/lifecycle/task-visuals');
26
+ // task 1004597: the one spelling of "this is platform upkeep" (kind infra, no goal).
27
+ const { UPKEEP_KIND } = require('../../modules/lifecycle/upkeep-home');
26
28
 
27
29
  // Valid enums (mirrors the server's task-classifier vocab; a friendly client-side check
28
30
  // so an obvious typo fails before the round-trip — the server stays authoritative).
@@ -87,6 +89,8 @@ function usage() {
87
89
  console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
88
90
  console.error(' bongos task visual <id> --remove remove it again');
89
91
  console.error(' bongos task visual <id> <image> --slot <name> attach or replace a named visual (e.g. after-phone-dark)');
92
+ console.error('\n Platform upkeep (a Bongos upgrade, maintenance) needs no goal: pass --kind infra');
93
+ console.error(" and leave out --goal, and it is filed under the version's maintenance goal.");
90
94
  console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
91
95
  console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
92
96
  }
@@ -111,6 +115,22 @@ async function resolveDefaultVersion(api) {
111
115
  return building[0].id;
112
116
  }
113
117
 
118
+ // goalRequiredHelp — PURE: what a builder reads when a task names no goal
119
+ // (task 1004597). Two ways forward: name a product goal, or — when the work is
120
+ // platform upkeep such as a Bongos upgrade — file it as kind infra with no goal.
121
+ function goalRequiredHelp(body) {
122
+ const lines = ['Every task belongs to a goal, and this one did not name one.'];
123
+ if (body && body.kind === UPKEEP_KIND) {
124
+ lines.push(`Version ${body.version_id} is not being built right now, so it has no maintenance goal.`);
125
+ lines.push('Re-run with --version <the version being built> (see `bongos status`).');
126
+ } else {
127
+ lines.push(' - Product work: re-run with --goal <id> (`bongos status` lists the goals).');
128
+ lines.push(' - Platform upkeep (a Bongos upgrade or other maintenance): re-run with --kind infra and no --goal.');
129
+ lines.push(" It goes into this version's maintenance goal.");
130
+ }
131
+ return lines.join('\n');
132
+ }
133
+
114
134
  async function cmdNew(args) {
115
135
  const { body, errors } = buildTaskBody(args);
116
136
  if (errors.length) { console.error('task new: ' + errors.join('; ') + '\n'); usage(); process.exit(2); }
@@ -120,7 +140,7 @@ async function cmdNew(args) {
120
140
  if (!body.version_id) {
121
141
  body.version_id = await resolveDefaultVersion(api);
122
142
  if (!body.version_id) {
123
- console.error('task new: could not resolve an active version — pass --version <id> (see `bongos status`).');
143
+ console.error('task new: could not resolve an active version — pass --version <id> (see `npx bongos status`).');
124
144
  process.exit(2);
125
145
  }
126
146
  }
@@ -128,7 +148,13 @@ async function cmdNew(args) {
128
148
  if (r.status === 403) {
129
149
  console.error('Creating a task is Archon-gated (creating work shapes scope). Your rank is lower, so the server declined.');
130
150
  console.error('To propose it instead, capture an idea for triage:');
131
- console.error(' bongos exec scripts/gds/capture.js "<your idea>"');
151
+ console.error(' npx bongos exec scripts/gds/capture.js "<your idea>"');
152
+ process.exit(1);
153
+ }
154
+ // task 1004597: a goal-less filing is refused unless it is platform upkeep.
155
+ // Say both ways forward in plain words instead of dumping the raw error.
156
+ if (r.status === 400 && errCode(r.data) === 'goal_id_required') {
157
+ console.error(goalRequiredHelp(body));
132
158
  process.exit(1);
133
159
  }
134
160
  if (r.status !== 201 || !r.data?.task) {
@@ -141,8 +167,9 @@ async function cmdNew(args) {
141
167
  console.log(`✓ Created task #${task.id} [${task.status}] — ${task.title}`);
142
168
  console.log(` kind=${task.kind} · discipline=${task.discipline}${task.priority ? ` · P${task.priority}` : ''}${task.goal_id ? ` · goal ${task.goal_id}` : ''}`);
143
169
  console.log(` ${hallLink(API_BASE, task.id)}`);
170
+ if (r.data.goal_home === 'maintenance') console.log(` Filed under ${body.version_id}'s maintenance goal, the standing home for platform upkeep.`);
144
171
  if (task.status !== 'ready') console.log(` (status '${task.status}' — pass --ready to make it claimable now, deps permitting)`);
145
- console.log(` next: bongos claim ${task.id}`);
172
+ console.log(` next: npx bongos claim ${task.id}`);
146
173
  }
147
174
 
148
175
  async function cmdShow(args) {
@@ -243,4 +270,4 @@ if (require.main === module) {
243
270
  main().catch((err) => { console.error('fatal:', err); process.exit(1); });
244
271
  }
245
272
 
246
- module.exports = { buildTaskBody, VALID_KINDS, VALID_DISCIPLINES, csv, hallLink };
273
+ module.exports = { buildTaskBody, goalRequiredHelp, VALID_KINDS, VALID_DISCIPLINES, csv, hallLink };
@@ -0,0 +1,73 @@
1
+ // scripts/gds/upgrade-operator.js — "who upgrades this project?" for `bongos upgrade`
2
+ // run from a builder's own computer (task 1004596).
3
+ //
4
+ // A hosted project (its Settings → Software update button leads to the hub — door kind
5
+ // 'hub', see src/bongos/routes/core-update.js updateDoor) lives on its operator's server.
6
+ // Its builders work from a laptop checkout that has no database and no server access. Run
7
+ // there, `bongos upgrade` used to say only "cannot determine which database to migrate.
8
+ // Set PGDATABASE…" — and an assistant reading that, plus the release recipe, wrote the
9
+ // builder server steps full of guessed names for a machine they cannot reach. This module
10
+ // replaces that dead end with the one supported path: the operator upgrades it, and here
11
+ // is how to ask.
12
+ //
13
+ // THE CONDITION, and why it cannot touch the real operator path: BOTH must hold —
14
+ // 1. no database is known in this checkout (no PGDATABASE, no db.database in the
15
+ // branding pack). On the server a database is always known, or upgrade.js already
16
+ // refuses there; so the server never reaches step 2.
17
+ // 2. the project, asked through this builder's own CLI session, says its update door is
18
+ // the hub's. No session, no answer, an older core, a self-hosted or provisioning
19
+ // instance → no message, and upgrade.js behaves exactly as before.
20
+
21
+ // Ask the project (via this builder's CLI session) where its Update button leads.
22
+ // Returns { kind, url, hall } or null on ANY failure — a missing answer is never a verdict.
23
+ async function readUpdateDoor({ timeoutMs = 5000 } = {}) {
24
+ try {
25
+ const { readSessionToken, dispatchedFetch, noKeepAliveDispatcher, CLI_USER_AGENT, hallBase } = require('./cli-lib');
26
+ const session = readSessionToken();
27
+ if (!session) return null;
28
+ const res = await dispatchedFetch(`${session.apiBase}/api/bongos/software-update`, {
29
+ headers: { Authorization: `Bearer ${session.token}`, 'User-Agent': CLI_USER_AGENT },
30
+ signal: AbortSignal.timeout(timeoutMs),
31
+ dispatcher: noKeepAliveDispatcher(),
32
+ });
33
+ if (!res.ok) return null;
34
+ const body = await res.json();
35
+ const door = body && body.door;
36
+ if (!door || typeof door.kind !== 'string') return null;
37
+ return { kind: door.kind, url: door.url || null, hall: hallBase(session.apiBase) };
38
+ } catch { return null; }
39
+ }
40
+
41
+ // The door when the operator runs this project and this checkout is not where it runs;
42
+ // null otherwise (including whenever the answer cannot be read).
43
+ async function operatorRunsThisProject({ instanceDir }, deps = {}) {
44
+ const { db } = (deps.resolveInstanceDb || require('./upgrade').resolveInstanceDb)({ instanceDir }, deps.run);
45
+ if (db) return null;
46
+ const door = await (deps.readUpdateDoor || readUpdateDoor)();
47
+ return door && door.kind === 'hub' ? door : null;
48
+ }
49
+
50
+ // What the builder (and any assistant reading over their shoulder) is told. Plain words,
51
+ // one supported path, no server steps and nothing for anyone to fill in.
52
+ function operatorRunMessage(door = {}) {
53
+ const settings = door.hall ? `${door.hall}/settings#software-update` : 'Settings → Software update';
54
+ return [
55
+ 'This project is upgraded by its operator, not from this computer.',
56
+ 'It runs on the operator\'s server, and nothing you run here can upgrade it.',
57
+ '',
58
+ 'To ask for the upgrade:',
59
+ ` • Open ${settings} and use the button there.`,
60
+ ' • Or, from this terminal: bongos feedback "Please upgrade this project to the latest version"',
61
+ '',
62
+ 'There are no server steps for you to take. An assistant should not write any, or guess',
63
+ 'server, database or service names for this project — pass the request on instead.',
64
+ ].join('\n') + '\n';
65
+ }
66
+
67
+ // The message to show, or null — what `bongos upgrade` (upgrade.js main) asks before anything else.
68
+ async function operatorNotice({ instanceDir }, deps = {}) {
69
+ const door = await operatorRunsThisProject({ instanceDir }, deps);
70
+ return door ? operatorRunMessage(door) : null;
71
+ }
72
+
73
+ module.exports = { readUpdateDoor, operatorRunsThisProject, operatorRunMessage, operatorNotice };
@@ -1433,7 +1433,7 @@ function parseArgs(argv) {
1433
1433
  };
1434
1434
  }
1435
1435
 
1436
- async function main(argv = process.argv.slice(2)) {
1436
+ async function main(argv = process.argv.slice(2), deps = {}) {
1437
1437
  if (hasFlag('--help', argv) || argv[0] === 'help') {
1438
1438
  process.stdout.write([
1439
1439
  'bongos upgrade — pull the Cloud Bongos core to a new version and apply it.',
@@ -1471,9 +1471,14 @@ async function main(argv = process.argv.slice(2)) {
1471
1471
  }
1472
1472
  // Backward-compatible: bare `bongos upgrade` (no --to) is the documented module-compatibility
1473
1473
  // PRE-CHECK — unchanged behavior, delegated to the module CLI. A --to switches to the real channel.
1474
+ // task 1004596: from a builder's own checkout of a project its OPERATOR runs, say who upgrades it and how to ask — never a server-only refusal (upgrade-operator.js has the condition).
1475
+ const operatorMsg = await (deps.operatorNotice || require('./upgrade-operator').operatorNotice)({ instanceDir: path.resolve(arg('--instance', argv) || process.cwd()) });
1474
1476
  if (!arg('--to', argv)) {
1475
- return require('./module').cmdUpgrade(argv);
1477
+ const code = await (deps.cmdUpgrade || require('./module').cmdUpgrade)(argv);
1478
+ if (operatorMsg) process.stdout.write(`\n${operatorMsg}`);
1479
+ return code;
1476
1480
  }
1481
+ if (operatorMsg) { process.stderr.write(`\n✖ ${operatorMsg}`); return 1; }
1477
1482
  const res = await runUpgrade(parseArgs(argv));
1478
1483
  if (!res.ok) { process.stderr.write(`\n✖ ${res.error || 'upgrade failed'}\n${require('./upgrade-outcome').outcomeLine(res)}`); return 1; } // the outcome line: how far it got, for the deploy runner (task 1004446)
1479
1484
  return 0;
@@ -180,6 +180,9 @@ const CANNED_WRITES = [
180
180
  // a vote cast from the street's matter card (task 1004528), so the card's
181
181
  // answer (the toast, the building lighting) renders here too.
182
182
  ['POST', /^government\/board\/items\/\d+\/votes$/, 'government__board__items__votes.post'],
183
+ // Settings → Software update's "Ask for this upgrade" (task 1004595), so the
184
+ // pending line the panel draws after the press renders here too.
185
+ ['POST', /^software-update\/request$/, 'software-update__request.post'],
183
186
  ];
184
187
 
185
188
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -33,6 +33,7 @@ const { readCoreUpdate, readCoreUpgrades } = require('../core-update');
33
33
  const { readSoftwareUpdate } = require('../software-update');
34
34
  const seams = require('../../module-seams');
35
35
  const { loadIdpConfig } = require('../auth-config');
36
+ const upgradeRequest = require('../upgrade-request');
36
37
 
37
38
  // WHERE A HOSTED PROJECT'S OWNER TAKES THE UPGRADE (task 1004174, ADR 0339): the hub's
38
39
  // own deploy door, scoped to this project — `<hub>/deploy?project=<slug>`. The act stays
@@ -73,12 +74,22 @@ function hubManageLinks(config) {
73
74
  // local — this hall runs the door: its own /deploy.
74
75
  // hub — a hosted project: the hub's door for this project (ADR 0339 D3).
75
76
  // cli — neither: a self-hosted instance, upgraded on its server with `bongos upgrade`.
76
- function updateDoor({ provisioning, idp }) {
77
+ // A hosted project that does not federate its sign-in (a cloud-host, or one keeping its own
78
+ // GitHub app) has no idp config to derive the hub from, so provisioning writes the door's
79
+ // address as <PREFIX>_HUB_UPGRADE_URL (task 1004608). Without it, those owners were told to
80
+ // run `bongos upgrade` on a server the platform runs. Only the hub's /deploy path is taken.
81
+ const HUB_UPGRADE_URL_RE = /^https?:\/\/[^\s"'<>/]+\/deploy\?project=[A-Za-z0-9._%-]+$/;
82
+ function updateDoor({ provisioning, idp, hubUpgradeUrl }) {
77
83
  if (provisioning) return { kind: 'local', url: '/deploy' };
78
- const url = hubDeployUrl(idp);
84
+ const url = hubDeployUrl(idp)
85
+ || (typeof hubUpgradeUrl === 'string' && HUB_UPGRADE_URL_RE.test(hubUpgradeUrl.trim()) ? hubUpgradeUrl.trim() : null);
79
86
  return url ? { kind: 'hub', url } : { kind: 'cli', url: null };
80
87
  }
81
88
 
89
+ function safeHubUpgradeUrl() {
90
+ try { return require('../../instance-config').resolveEnv('HUB_UPGRADE_URL') || null; } catch { return null; }
91
+ }
92
+
82
93
  function provisioningEnabled() {
83
94
  try { return require('../../modules').isModuleEnabled('provisioning'); } catch { return false; }
84
95
  }
@@ -164,16 +175,57 @@ async function resolveChannel(selfChannel) {
164
175
  return { channel, source: channel ? 'deploy' : 'default' };
165
176
  }
166
177
 
178
+ // "Ask for this upgrade" (task 1004595): what src/bongos/upgrade-request.js needs from
179
+ // the outside world, resolved per request so a test can stand every piece in. The receipt
180
+ // goes into the SAME store the public redeem route reads (routes/feedback.js).
181
+ function defaultAskDeps() {
182
+ const ps = require('../project-settings');
183
+ const fb = require('./feedback');
184
+ return {
185
+ hub: fb.feedbackHub(),
186
+ host: configuredHost(),
187
+ getSetting: ps.getSetting,
188
+ setSetting: ps.setSetting,
189
+ issueReceipt: (r) => fb.defaultReceiptStore().issue(r),
190
+ };
191
+ }
192
+
193
+ // The host this project answers on, from its OWN branding pack (domains is server-only):
194
+ // the origin the hub calls back to redeem the receipt. Never read from the request: a
195
+ // Host or X-Forwarded-Host header is the caller's to write, and would let them point the
196
+ // hub's call-back at any address they like. '' when the pack names none.
197
+ function configuredHost() {
198
+ try {
199
+ const d = require('../../branding').branding().domains || {};
200
+ return new URL(d.publicOrigin || d.buildersOrigin).host;
201
+ } catch { return ''; }
202
+ }
203
+
204
+ // The page's ask state. Fail-soft: a stored row that cannot be read is "not asked yet",
205
+ // never a 500 on the whole Software update panel.
206
+ async function readAsk({ update, door, deps }) {
207
+ const offered = update && update.reading === 'ok' ? update.offered : null;
208
+ const base = { offered, host: upgradeRequest.projectHost(deps.host), doorKind: door.kind, hub: deps.hub };
209
+ let stored = null;
210
+ if (offered && upgradeRequest.isOperatorRun(base)) {
211
+ try { stored = upgradeRequest.parseStored(await deps.getSetting(upgradeRequest.SETTING_KEY)); } catch { stored = null; }
212
+ }
213
+ return upgradeRequest.askState({ ...base, stored });
214
+ }
215
+
167
216
  function buildCoreUpdateRouter({
168
217
  coreUpdate = readCoreUpdate,
169
218
  selfChannel = storedSelfChannel,
170
219
  upgrades = readCoreUpgrades,
171
220
  softwareUpdate = readSoftwareUpdate,
172
221
  canUpdate = holdsCorePin,
173
- doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig() }),
222
+ doorInputs = () => ({ provisioning: provisioningEnabled(), idp: safeIdpConfig(), hubUpgradeUrl: safeHubUpgradeUrl() }),
223
+ askDeps = defaultAskDeps,
174
224
  } = {}) {
175
225
  contributeCoreUpdateToDocket();
176
226
  const router = express.Router();
227
+ // One send at a time: a double press waits for the first and is answered from its row.
228
+ let asking = null;
177
229
 
178
230
  router.get(
179
231
  '/core-update',
@@ -229,10 +281,51 @@ function buildCoreUpdateRouter({
229
281
  resolveChannel(selfChannel).then(async ({ channel, source }) => ({ ...(await softwareUpdate({ running, channel })), channel_source: source })),
230
282
  canUpdate(req),
231
283
  ]);
232
- res.json({ ok: true, update, door: updateDoor(doorInputs()), can_update: can === true });
284
+ const door = updateDoor(doorInputs());
285
+ const ask = await readAsk({ update, door, deps: askDeps() });
286
+ res.json({ ok: true, update, door, can_update: can === true, ask });
233
287
  }, { errorCode: 'software_update_failed', message: false }),
234
288
  );
235
289
 
290
+ // POST /software-update/request — "Ask for this upgrade" (task 1004595). On a project
291
+ // the hub's operator runs, sends one request for the version this project is offered
292
+ // to the operator, through the direct feedback door, and remembers it so the page can
293
+ // say it is pending. The version is the server's own reading, never the caller's.
294
+ // Idempotent per version; a different version waits out a 24h cooldown (429).
295
+ // rank: core.pin.move — the same atom as the Update button the panel draws.
296
+ router.post(
297
+ '/software-update/request',
298
+ auth.requireBuilder,
299
+ auth.requirePermission('core.pin.move'),
300
+ asyncHandler('POST /software-update/request', async (req, res) => {
301
+ while (asking) { try { await asking; } catch { /* the next one tries for itself */ } }
302
+ const run = (async () => {
303
+ let running = null;
304
+ try { running = require('../../module-api').CORE_VERSION; } catch { /* version unknown */ }
305
+ const { channel } = await resolveChannel(selfChannel);
306
+ const update = await softwareUpdate({ running, channel });
307
+ const door = updateDoor(doorInputs());
308
+ const deps = askDeps();
309
+ return upgradeRequest.requestUpgrade({
310
+ host: deps.host,
311
+ running,
312
+ wanted: update && update.reading === 'ok' ? update.offered : null,
313
+ builder: req.builder,
314
+ doorKind: door.kind,
315
+ }, deps);
316
+ })();
317
+ asking = run;
318
+ let out;
319
+ try { out = await run; } finally { if (asking === run) asking = null; }
320
+ if (out.status >= 400) {
321
+ const { error, message, ...details } = out.body;
322
+ if (details.retry_after_seconds) res.set('Retry-After', String(details.retry_after_seconds));
323
+ return res.fail(error, { status: out.status, message, details });
324
+ }
325
+ res.status(out.status).json(out.body);
326
+ }, { errorCode: 'upgrade_request_failed', message: false }),
327
+ );
328
+
236
329
  // GET /project/manage-link — the hub's page for THIS project, where its owner takes it
237
330
  // offline or disconnects it (task 1004570). On a hosted project the owner is its Archon,
238
331
  // so the read is Archon-only: a builder below that is refused and the hall draws no
@@ -254,4 +347,5 @@ function buildCoreUpdateRouter({
254
347
  module.exports = buildCoreUpdateRouter;
255
348
  module.exports.hubManageLinks = hubManageLinks;
256
349
  module.exports.hubDeployUrl = hubDeployUrl;
350
+ module.exports.updateDoor = updateDoor;
257
351
  module.exports.coreUpdateDocketMatters = coreUpdateDocketMatters;
@@ -77,7 +77,16 @@ function makeReceiptStore({ now = Date.now, ttlMs = RECEIPT_TTL_MS, max = RECEIP
77
77
  };
78
78
  }
79
79
 
80
- function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } = {}) {
80
+ // The process's one receipt store. Shared, because a receipt is redeemed at the public
81
+ // route below whoever issued it: the CLI's POST /feedback/receipt, or this project's own
82
+ // "Ask for this upgrade" (src/bongos/upgrade-request.js, task 1004595).
83
+ let sharedStore = null;
84
+ function defaultReceiptStore() {
85
+ if (!sharedStore) sharedStore = makeReceiptStore();
86
+ return sharedStore;
87
+ }
88
+
89
+ function buildFeedbackRouter({ store = defaultReceiptStore(), hub = feedbackHub } = {}) {
81
90
  const router = express.Router();
82
91
 
83
92
  // rank: any signed-in builder — a receipt only says "this builder sent this
@@ -120,5 +129,6 @@ function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } =
120
129
 
121
130
  module.exports = buildFeedbackRouter;
122
131
  module.exports.makeReceiptStore = makeReceiptStore;
132
+ module.exports.defaultReceiptStore = defaultReceiptStore;
123
133
  module.exports.feedbackHub = feedbackHub;
124
134
  module.exports.DEFAULT_FEEDBACK_HUB = DEFAULT_FEEDBACK_HUB;
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/upgrade-request.js — "Ask for this upgrade" on Settings → Software update
4
+ // (task 1004595).
5
+ //
6
+ // WHY. A project that Cloud Bongos hosts is upgraded by the people who run its server,
7
+ // not by its owner. Its Settings page said "Update ready" and then nothing: no button,
8
+ // no next step (Will, mercury, 2026-10-06, 46 versions behind with nobody told). This
9
+ // file gives that owner one press that reaches the operator, and remembers that it was
10
+ // pressed so the page can say the request is pending.
11
+ //
12
+ // THE CHANNEL IS ONE THAT ALREADY EXISTS: the direct feedback door (task 1004591). The
13
+ // project issues a one-time receipt for the signed-in builder in its OWN receipt store
14
+ // (src/bongos/routes/feedback.js), then posts the request straight to the hub's
15
+ // POST /api/bongos/sso/feedback/direct. The hub redeems the receipt at this project's
16
+ // public URL, so the request lands in the maintainers' backlog attributed to the person
17
+ // who really pressed the button, with no new credential and no new hub route.
18
+ //
19
+ // ONE REQUEST PER TARGET VERSION. Asking again for a version already asked for is
20
+ // answered from the stored row and sends nothing. A different version may be asked for
21
+ // once the cooldown has passed, so a project that falls further behind can say so,
22
+ // without a press per patch release flooding the backlog.
23
+
24
+ const crypto = require('node:crypto');
25
+
26
+ const SETTING_KEY = 'software_update.request';
27
+ const COOLDOWN_MS = 24 * 60 * 60 * 1000;
28
+ const HUB_TIMEOUT_MS = 15000;
29
+
30
+ const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
31
+ const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
32
+
33
+ // The host this hall answers on, as the hub will need it to redeem the receipt: a bare
34
+ // public DNS name. Anything else (an IP, a port, localhost, a dev name) cannot be
35
+ // reached by the hub, so it is not askable. Mirrors the hub's own projectOrigin check.
36
+ function projectHost(raw) {
37
+ const host = String(raw || '').trim().toLowerCase();
38
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
39
+ if (/^\d+(\.\d+)+$/.test(host)) return null;
40
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
41
+ return host;
42
+ }
43
+
44
+ function hubHostOf(hub) {
45
+ try { return new URL(String(hub)).host.toLowerCase(); } catch { return null; }
46
+ }
47
+
48
+ // Is this a project the hub's operator runs? Its door is the hub's, or it answers on a
49
+ // name under the hub's own domain (the same rule the hub uses to call a sender "verified").
50
+ function isOperatorRun({ doorKind, host, hub }) {
51
+ if (doorKind === 'local' || !hub) return false;
52
+ if (doorKind === 'hub') return true;
53
+ const h = hubHostOf(hub);
54
+ return !!(h && host && (host === h || host.endsWith(`.${h}`)));
55
+ }
56
+
57
+ // The stored request, or null. A row this file did not write reads as no request.
58
+ function parseStored(row) {
59
+ if (!row || typeof row.value !== 'string') return null;
60
+ let v;
61
+ try { v = JSON.parse(row.value); } catch { return null; }
62
+ if (!v || !VERSION_RE.test(String(v.version || ''))) return null;
63
+ const at = Date.parse(v.requested_at);
64
+ if (!Number.isFinite(at)) return null;
65
+ return {
66
+ version: String(v.version),
67
+ requested_at: new Date(at).toISOString(),
68
+ by: typeof v.by === 'string' && LOGIN_RE.test(v.by) ? v.by : null,
69
+ };
70
+ }
71
+
72
+ // What the page needs: may it offer the button, and what was asked before.
73
+ // `ask_again_after` is when a request for a DIFFERENT version would be taken.
74
+ function askState({ offered, host, doorKind, hub, stored }) {
75
+ const available = !!offered && isOperatorRun({ doorKind, host, hub }) && !!projectHost(host);
76
+ const ask = { available, hub_host: hub ? hubHostOf(hub) : null, requested: stored || null, ask_again_after: null };
77
+ if (stored) ask.ask_again_after = new Date(Date.parse(stored.requested_at) + COOLDOWN_MS).toISOString();
78
+ return ask;
79
+ }
80
+
81
+ // The text the operator reads. Its first line becomes the hub task's title.
82
+ function requestMessage({ host, running, wanted, login }) {
83
+ const who = login && LOGIN_RE.test(login) ? `@${login}` : 'its owner';
84
+ return [
85
+ `Upgrade request: ${host} asks for core ${wanted} (it runs ${running || 'an unknown version'})`,
86
+ '',
87
+ `${who} pressed "Ask for this upgrade" in Settings, Software update on ${host}.`,
88
+ 'This project cannot upgrade itself: the people who run its server do it.',
89
+ `Move its core to ${wanted} or newer, or tell them when it will be.`,
90
+ ].join('\n');
91
+ }
92
+
93
+ /**
94
+ * Send one upgrade request, or answer from the stored one. Never throws for an
95
+ * expected refusal; returns { status, body } for the route to send.
96
+ *
97
+ * deps: { hub, issueReceipt(receipt) → id|null, fetchImpl, getSetting, setSetting, now }
98
+ */
99
+ async function requestUpgrade({ host, running, wanted, builder, doorKind }, deps) {
100
+ const now = deps.now || Date.now;
101
+ const hub = deps.hub;
102
+ const target = projectHost(host);
103
+ if (!wanted || !VERSION_RE.test(String(wanted))) return { status: 409, body: { error: 'nothing_to_ask', message: 'This project is up to date.' } };
104
+ if (!target || !isOperatorRun({ doorKind, host: target, hub })) {
105
+ return { status: 409, body: { error: 'not_operator_run', message: 'This project is not upgraded by an operator, so there is no one to ask.' } };
106
+ }
107
+
108
+ const stored = parseStored(await deps.getSetting(SETTING_KEY));
109
+ if (stored && stored.version === wanted) return { status: 200, body: { ok: true, already: true, request: stored } };
110
+ if (stored && now() < Date.parse(stored.requested_at) + COOLDOWN_MS) {
111
+ const retry = Math.ceil((Date.parse(stored.requested_at) + COOLDOWN_MS - now()) / 1000);
112
+ return { status: 429, body: { error: 'asked_recently', message: 'This project already asked for an upgrade today. Ask again tomorrow.', retry_after_seconds: retry, request: stored } };
113
+ }
114
+
115
+ const login = builder && typeof builder.github_login === 'string' && LOGIN_RE.test(builder.github_login) ? builder.github_login : null;
116
+ const message = requestMessage({ host: target, running, wanted, login });
117
+ const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
118
+ const receipt = deps.issueReceipt({
119
+ github_id: String(builder && builder.github_id),
120
+ github_login: login,
121
+ sha256,
122
+ kind: 'bug',
123
+ core_version: running || null,
124
+ });
125
+ if (!receipt) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in ten minutes.', retry_after_seconds: 600 } };
126
+
127
+ let res;
128
+ try {
129
+ res = await (deps.fetchImpl || fetch)(`${String(hub).replace(/\/+$/, '')}/api/bongos/sso/feedback/direct`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
132
+ body: JSON.stringify({ origin: `https://${target}`, receipt, message, kind: 'bug', context: {} }),
133
+ redirect: 'error',
134
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
135
+ });
136
+ } catch {
137
+ return { status: 502, body: { error: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } };
138
+ }
139
+ if (res.status === 429) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in an hour.', retry_after_seconds: 3600 } };
140
+ if (res.status !== 202) {
141
+ return { status: 502, body: { error: 'operator_unreachable', message: `The request was not taken (HTTP ${res.status}). Try again later.` } };
142
+ }
143
+
144
+ const request = { version: String(wanted), requested_at: new Date(now()).toISOString(), by: login };
145
+ await deps.setSetting(SETTING_KEY, JSON.stringify(request), builder && builder.id != null ? builder.id : null);
146
+ return { status: 201, body: { ok: true, already: false, request } };
147
+ }
148
+
149
+ module.exports = {
150
+ SETTING_KEY,
151
+ COOLDOWN_MS,
152
+ projectHost,
153
+ isOperatorRun,
154
+ parseStored,
155
+ askState,
156
+ requestMessage,
157
+ requestUpgrade,
158
+ };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.70'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.72'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');