@bongos/core 1.21.69 → 1.21.71

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 +119 -64
  2. package/.claude/skills/bongos-feedback/SKILL.md +10 -46
  3. package/.claude/skills/goal-create/SKILL.md +21 -3
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +8 -2
  6. package/clients/bongos-client/index.cjs +8 -2
  7. package/clients/bongos-client/index.d.ts +15 -6
  8. package/clients/bongos-client/index.mjs +8 -2
  9. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +196 -19
  12. package/docs/api-reference.md +9 -6
  13. package/docs/copy-inventory.md +25 -21
  14. package/docs/copy-registry.json +76 -30
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/page-readings.json +8 -8
  18. package/docs/recipes/core-release-pipeline.md +6 -0
  19. package/docs/recipes/upgrading-the-core.md +2 -0
  20. package/modules/hall-ui/public/settings-software-update.js +77 -2
  21. package/modules/hall-ui/public/settings.html +1 -1
  22. package/modules/lifecycle/db-tasks.js +12 -0
  23. package/modules/lifecycle/goal-spec-task.js +129 -0
  24. package/modules/lifecycle/routes/goals.js +7 -9
  25. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  26. package/modules/lifecycle/upkeep-home.js +55 -0
  27. package/modules/platform-identity/instance-feedback.js +108 -7
  28. package/modules/platform-identity/routes/sso.js +71 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +42 -0
  32. package/scripts/gds/claude-materialize.js +125 -31
  33. package/scripts/gds/claude-md-template.js +47 -10
  34. package/scripts/gds/cli-lib.js +4 -4
  35. package/scripts/gds/feedback-send.js +146 -73
  36. package/scripts/gds/paste-token.js +1 -1
  37. package/scripts/gds/provision-net.js +1 -8
  38. package/scripts/gds/provision-units.js +28 -2
  39. package/scripts/gds/reauth.js +1 -1
  40. package/scripts/gds/run-unit-tests.js +3 -0
  41. package/scripts/gds/setup.js +1 -1
  42. package/scripts/gds/start.js +4 -4
  43. package/scripts/gds/surface.js +15 -3
  44. package/scripts/gds/task.js +32 -5
  45. package/scripts/gds/upgrade-operator.js +73 -0
  46. package/scripts/gds/upgrade.js +7 -2
  47. package/scripts/hall-preview/server.js +3 -0
  48. package/src/bongos/platform-visibility-gate.js +3 -0
  49. package/src/bongos/routes/core-update.js +98 -4
  50. package/src/bongos/routes/feedback.js +100 -76
  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/instance_feedback.mjs +267 -68
  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
@@ -86,7 +86,7 @@ const API_BASE = RESOLVED.base;
86
86
  function usage(extra) {
87
87
  if (extra) console.error(extra);
88
88
  console.error('');
89
- console.error('Usage: bongos paste-token <token> [--api-base <url>]');
89
+ console.error('Usage: npx bongos paste-token <token> [--api-base <url>]');
90
90
  console.error(' (inside a core checkout: node scripts/gds/paste-token.js <token>)');
91
91
  console.error('');
92
92
  // Fall back to the inferred base for this hint when --api-base was the thing that
@@ -12,14 +12,7 @@
12
12
  const path = require('node:path');
13
13
  const crypto = require('node:crypto');
14
14
  const { CONFIG } = require('./provision-config.js');
15
- const { provisionedEnvPrefix } = require('./provision-units.js');
16
-
17
- function federationHubOrigin() {
18
- const explicit = process.env.PROVISION_HUB_ORIGIN;
19
- if (explicit) return String(explicit).replace(/\/+$/, '');
20
- const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
21
- return own ? String(own).replace(/\/+$/, '') : null;
22
- }
15
+ const { federationHubOrigin, provisionedEnvPrefix } = require('./provision-units.js'); // lives beside the hub-upgrade address it also feeds (task 1004608)
23
16
 
24
17
  // Whether provisioning should federate this instance's sign-in through the hub.
25
18
  //
@@ -258,13 +258,15 @@ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
258
258
  // Postgres with every sibling and the control plane, so its pool may not take pg's
259
259
  // default 10 of the server's 97. src/bongos/pool.js reads it bare.
260
260
  const poolLine = `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
261
+ // Where this project's Update button goes (task 1004608) — see hubUpgradeEnvVarsFor.
262
+ const hubUpgradeLines = Object.entries(hubUpgradeEnvVarsFor(inst)).map(([k, v]) => `${k}=${v}\n`).join('');
261
263
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
262
264
  ${credComment}
263
265
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
264
266
  ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
265
267
  ${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
266
268
  PGDATABASE=${dbName(inst)}
267
- ${poolLine}${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
269
+ ${poolLine}${dbLines}${fedLines}${hubUpgradeLines}${settingsEnvLines(inst, prefix)}`;
268
270
  }
269
271
  function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
270
272
  return path.posix.join(etcBase, inst.slug, 'web.env');
@@ -295,7 +297,7 @@ function originEnvVarsFor(inst) {
295
297
  function settingsEnvVarsFor(inst, envBody) {
296
298
  const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
297
299
  const prefix = inst && inst.hosting_shape === 'control-plane' ? selfEnvPrefix({ envBody }) : provisionedEnvPrefix();
298
- return settingsEnvVars(inst, prefix);
300
+ return { ...settingsEnvVars(inst, prefix), ...hubUpgradeEnvVarsFor(inst) };
299
301
  }
300
302
 
301
303
  // The env-var prefix the CONTROL PLANE's own web process resolves (task 1004334). The
@@ -414,8 +416,32 @@ function renamedEnvCarry(body) {
414
416
  // <PREFIX>_HUB_ORIGIN (what auth.js loadIdpConfig reads). Explicit PROVISION_HUB_ORIGIN
415
417
  // wins; otherwise the control plane's OWN public origin (<PREFIX>_PUBLIC_ORIGIN, the
416
418
  // env the hub already runs under). Null → not hub-configured → shouldFederate is false.
419
+ function federationHubOrigin() {
420
+ const explicit = process.env.PROVISION_HUB_ORIGIN;
421
+ if (explicit) return String(explicit).replace(/\/+$/, '');
422
+ const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
423
+ return own ? String(own).replace(/\/+$/, '') : null;
424
+ }
425
+
426
+ // WHERE A HOSTED PROJECT'S UPDATE BUTTON GOES (task 1004608): the hub's deploy door for
427
+ // THIS project, written as <PREFIX>_HUB_UPGRADE_URL. The hall used to learn the hub only
428
+ // from the sign-in federation block, which only a co-tenant gets, so every cloud-host
429
+ // project (and any that keeps its own GitHub app) told its owner to run `bongos upgrade`
430
+ // on a server the platform runs. The door admits the owner of both tenant shapes
431
+ // (modules/provisioning/core-upgrade-authz.js), so both get the address. Not a secret and
432
+ // grants nothing: the door re-checks every request. Empty for the tenant shapes when no hub
433
+ // origin resolves (a key that came and went would leave a stale line behind); absent for
434
+ // every other shape, which has no door on this hub.
435
+ const HUB_UPGRADE_SHAPES = new Set(['co-tenant', 'cloud-host']);
436
+ function hubUpgradeEnvVarsFor(inst) {
437
+ if (!inst || !HUB_UPGRADE_SHAPES.has(inst.hosting_shape) || !inst.slug) return {};
438
+ const origin = federationHubOrigin();
439
+ return { [`${provisionedEnvPrefix()}_HUB_UPGRADE_URL`]: origin ? `${origin}/deploy?project=${encodeURIComponent(String(inst.slug))}` : '' };
440
+ }
417
441
 
418
442
  module.exports = {
443
+ federationHubOrigin,
444
+ hubUpgradeEnvVarsFor,
419
445
  ENV_PREFIX_PACK,
420
446
  envPrefixMismatch,
421
447
  normalizePackEnvPrefix,
@@ -96,7 +96,7 @@ function printGuidance({ opened }) {
96
96
  console.log(" you're already signed in there via your browser cookie)");
97
97
  console.log(' 2. Under the "CLI Access" section, click "Re-issue CLI token".');
98
98
  console.log(' 3. Click "Copy" — it copies a one-line command that looks like:');
99
- console.log(' bongos paste-token <token>');
99
+ console.log(' npx bongos paste-token <token>');
100
100
  console.log(' 4. Paste that one line back to Claude. It will run it for you,');
101
101
  console.log(' verify the token, and write your new session.');
102
102
  console.log('');
@@ -106,6 +106,9 @@ const INTEGRATION = new Set([
106
106
  // never was a dedicated consumer-layout-boot workflow, whatever older notes say.
107
107
  'consumer_layout_boot',
108
108
  'claim_gate_open_blocker', 'claim_gate_rebase',
109
+ // task 1004585: a hall-made goal's spec task gates its other work — the edge, the
110
+ // claim gate's unsatisfiedDeps read, and the ship-time auto-promotion, on real Postgres.
111
+ 'goal_spec_task_db',
109
112
  // task 1003457: proves the blocker-resolve auto-promotion trigger (migration
110
113
  // 009, tightened by core_223) fires on the HALL'S OWN write path — the real
111
114
  // buildGdsRouter() HTTP routes, not a static SQL-text pin (blocker_resolve_guard.mjs,
@@ -356,7 +356,7 @@ async function finishSetup(builder) {
356
356
  console.log(` 3. Claim + ship: ${surfaceAction('claim', 'N')} → ${surfaceAction('ship')}`);
357
357
  // Claiming writes code, so it needs a checkout. In a terminal the public CLI answers
358
358
  // `bongos claim` with where to get one; say so here rather than let step 3 look self-serve.
359
- if (isTerminalSurface()) console.log(' (claim + ship run in a checkout — `bongos claim` shows how to get one)');
359
+ if (isTerminalSurface()) console.log(' (claim + ship run in a checkout — `npx bongos claim` shows how to get one)');
360
360
  console.log(' 4. Ship three works and you rise to Thetes — the whole queue opens.');
361
361
  }
362
362
  } catch (_) {
@@ -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
@@ -110,6 +110,9 @@ const EXEMPT = [
110
110
  /^\/downloads\//,
111
111
  { re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
112
112
  { re: new RegExp(`^${API}/access-requests/status$`), methods: ['GET'] },
113
+ // The hub redeeming a `bongos feedback` receipt (task 1004591): it has no
114
+ // session here, and a members-only project must still vouch for its builders.
115
+ { re: new RegExp(`^${API}/feedback/receipt/[a-z0-9_-]+$`), methods: ['GET'] },
113
116
  ];
114
117
 
115
118
  // The policy, read through the memoized pack. A pack that cannot be read at all
@@ -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;