@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.
- package/.bongos-core.json +123 -63
- package/.claude/skills/goal-create/SKILL.md +21 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +46 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +388 -377
- package/docs/copy-registry.json +567 -448
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +912 -931
- package/docs/recipes/core-release-pipeline.md +6 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/hall-ui/public/settings-software-update.js +77 -2
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/lifecycle/db-tasks.js +12 -0
- package/modules/lifecycle/goal-spec-task.js +129 -0
- package/modules/lifecycle/routes/goals.js +7 -9
- package/modules/lifecycle/routes/task-write-routes.js +11 -1
- package/modules/lifecycle/upkeep-home.js +55 -0
- package/modules/public-landing/public/projects-hall-live.states.json +7 -1
- package/modules/public-landing/public/projects.html +581 -0
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +43 -0
- package/scripts/gds/claude-materialize.js +125 -31
- package/scripts/gds/claude-md-template.js +47 -10
- package/scripts/gds/cli-lib.js +4 -4
- package/scripts/gds/feedback-send.js +3 -3
- package/scripts/gds/paste-token.js +1 -1
- package/scripts/gds/provision-net.js +1 -8
- package/scripts/gds/provision-units.js +28 -2
- package/scripts/gds/reauth.js +1 -1
- package/scripts/gds/run-unit-tests.js +3 -0
- package/scripts/gds/setup.js +1 -1
- package/scripts/gds/start.js +4 -4
- package/scripts/gds/surface.js +15 -3
- package/scripts/gds/task.js +32 -5
- package/scripts/gds/upgrade-operator.js +73 -0
- package/scripts/gds/upgrade.js +7 -2
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/routes/core-update.js +98 -4
- package/src/bongos/routes/feedback.js +11 -1
- package/src/bongos/upgrade-request.js +158 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +195 -18
- package/tests/cli_allowlist_permissions.mjs +6 -2
- package/tests/cli_surface.mjs +2 -2
- package/tests/fixtures/page-ledger.mjs +5 -0
- package/tests/goal_admission_override.mjs +1 -1
- package/tests/goal_category.mjs +1 -1
- package/tests/goal_create_spec_entry.mjs +61 -0
- package/tests/goal_spec_task.mjs +197 -0
- package/tests/goal_spec_task_db.mjs +95 -0
- package/tests/projects_hub_cloud_hop.mjs +303 -0
- package/tests/provision_settings_apply.mjs +25 -1
- package/tests/software_update.mjs +18 -1
- package/tests/start_env_guard.mjs +2 -2
- package/tests/upgrade_operator.mjs +105 -0
- package/tests/upgrade_request.mjs +348 -0
- package/tests/upkeep_home.mjs +156 -0
package/scripts/gds/start.js
CHANGED
|
@@ -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('
|
|
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++}.
|
|
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
|
|
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('
|
|
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:');
|
package/scripts/gds/surface.js
CHANGED
|
@@ -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) ?
|
|
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 };
|
package/scripts/gds/task.js
CHANGED
|
@@ -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 };
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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');
|