@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
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-spec-task.js — a goal made in the hall carries its own
4
+ // spec task, and the goal's other work waits behind it (task 1004585, ADR 0366 D3).
5
+ //
6
+ // WHY. The hall's goal form asks only what a person knows (a name, a description,
7
+ // a version, a category). The rest of a goal — its scope wall, its criteria, its
8
+ // first tasks — is inference, which /goal-create does in a session (ADR 0366 D2).
9
+ // So a hall-made goal arrives with a task that says exactly that, and nothing else
10
+ // in the goal can be claimed until it ships: an unspecced goal is visible as a
11
+ // task on the board, not a silence.
12
+ //
13
+ // HOW, with no new permission and no migration:
14
+ // - The spec task is an ordinary task, recognised by (source, source_ref) =
15
+ // ('goal-spec', 'goal-<id>-spec'). That pair is also what makes creating it
16
+ // idempotent (findTaskBySourceRef).
17
+ // - "Waits behind it" is an ordinary task→task dependency, so the claim gate's
18
+ // existing DEPS_NOT_SHIPPED refusal and the auto-promotion on ship do the rest.
19
+ // A goal-level edge would be simpler, but every task in the goal inherits it
20
+ // (unsatisfiedDeps), the spec task included, which would block it on itself.
21
+ // - The edge is added at the two places a task ENTERS a goal: db-tasks.createTask
22
+ // (every create vector passes through it) and db-tasks.updateTaskGoal (a move).
23
+
24
+ const SPEC_SOURCE = 'goal-spec';
25
+
26
+ function specSourceRef(goalId) {
27
+ return `goal-${goalId}-spec`;
28
+ }
29
+
30
+ // The spec task's fields, for db.createTask. `ready` so it can be claimed at once;
31
+ // a spike, because its output is a decision (the scope and the criteria) rather
32
+ // than code; ideator, the craft whose pack covers "the scopes of work ideas become".
33
+ function specTaskFields(goal, { createdBy = null } = {}) {
34
+ const id = goal.id;
35
+ return {
36
+ versionId: goal.version_id,
37
+ goalId: id,
38
+ title: `Spec goal ${id}: its scope, criteria and first tasks`,
39
+ description: [
40
+ `Goal ${id} ("${goal.title}") was created from the hall, which asks only for a name and a description. This task settles the rest, and every other task in the goal waits on it (ADR 0366 D3).`,
41
+ '',
42
+ 'Settle, in a session (run /goal-create against this goal):',
43
+ `1. The scope wall: which code areas the goal's tasks may touch. Set it with POST /goals/${id}/scope (the goal's owner or a manager; a protected area needs an Archon).`,
44
+ `2. Two to four done-when criteria, with POST /versions/${goal.version_id}/done-when under the goal (ADR 0154: the goal's owner or a manager).`,
45
+ '3. The first tasks, with their dependencies.',
46
+ '',
47
+ 'If you do not own or manage this goal, draft the scope and criteria and hand them to its owner rather than skipping them.',
48
+ '',
49
+ 'DONE WHEN: the goal has its scope (or a written reason it needs none), its criteria, and at least one claimable task.',
50
+ ].join('\n'),
51
+ status: 'ready',
52
+ kind: 'spike',
53
+ discipline: 'ideator',
54
+ source: SPEC_SOURCE,
55
+ sourceRef: specSourceRef(id),
56
+ createdBy,
57
+ };
58
+ }
59
+
60
+ // The goal's spec task if it is still open, else null. An abandoned spec task
61
+ // gates nothing new (the owner decided the goal needs no spec); a shipped one is done.
62
+ async function openSpecTaskId(goalId, client) {
63
+ if (goalId == null) return null;
64
+ const { rows } = await client.query(
65
+ `SELECT id FROM tasks
66
+ WHERE goal_id = $1 AND source = $2 AND source_ref = $3
67
+ AND status NOT IN ('shipped', 'abandoned')
68
+ ORDER BY id ASC
69
+ LIMIT 1`,
70
+ [goalId, SPEC_SOURCE, specSourceRef(goalId)]
71
+ );
72
+ return rows[0] ? Number(rows[0].id) : null;
73
+ }
74
+
75
+ // Make a task that just entered a goal wait on that goal's open spec task.
76
+ // Returns the spec task id it now waits on, or null. A task that IS the spec task
77
+ // is left alone. Safe without a cycle check only for a brand-new task (nothing can
78
+ // depend on it yet) — a moved task goes through addTaskDependency, which checks.
79
+ async function gateOnSpecTask(task, client, { addTaskDependency = null } = {}) {
80
+ if (!task || task.goal_id == null) return null;
81
+ if (task.source === SPEC_SOURCE && task.source_ref === specSourceRef(task.goal_id)) return null;
82
+ const specId = await openSpecTaskId(task.goal_id, client);
83
+ if (specId == null || specId === Number(task.id)) return null;
84
+ if (addTaskDependency) {
85
+ try {
86
+ await addTaskDependency(task.id, specId);
87
+ } catch (err) {
88
+ // A cycle means the spec task already waits on this task; gating it would
89
+ // deadlock both, so the move stands without the edge.
90
+ if (err && err.code === 'CYCLE') return null;
91
+ throw err;
92
+ }
93
+ } else {
94
+ await client.query(
95
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
96
+ VALUES ('task', $1, 'task', $2)
97
+ ON CONFLICT DO NOTHING`,
98
+ [task.id, specId]
99
+ );
100
+ }
101
+ return specId;
102
+ }
103
+
104
+ // Give a goal its spec task, safely re-runnable: if the goal already has an OPEN
105
+ // spec task, that one is returned and nothing is written, so a second run (a retry,
106
+ // or a future "re-offer the spec" path) never files a duplicate. Run it inside the
107
+ // caller's transaction so the read and the insert see the same snapshot.
108
+ async function ensureSpecTask({ db, goal, client, createdBy = null }) {
109
+ const existing = await openSpecTaskId(goal.id, client);
110
+ if (existing != null) return { specTask: { id: existing }, created: false };
111
+ const specTask = await db.createTask(specTaskFields(goal, { createdBy }), { client });
112
+ return { specTask, created: true };
113
+ }
114
+
115
+ // POST /goals's create step. Without the opt-in it is exactly db.createGoal. With it,
116
+ // the goal and its spec task land in ONE transaction: a goal without its spec task
117
+ // would be ungated, which is the state D3 exists to end.
118
+ async function createGoalWithSpec({ db, withTx, goalFields, withSpecTask = false }) {
119
+ if (!withSpecTask) return { goal: await db.createGoal(goalFields), specTask: null };
120
+ let goal;
121
+ let specTask = null;
122
+ await withTx(async (client) => {
123
+ goal = await db.createGoal(goalFields, { client });
124
+ ({ specTask } = await ensureSpecTask({ db, goal, client, createdBy: goalFields.createdBy }));
125
+ });
126
+ return { goal, specTask };
127
+ }
128
+
129
+ module.exports = { SPEC_SOURCE, specSourceRef, specTaskFields, openSpecTaskId, gateOnSpecTask, ensureSpecTask, createGoalWithSpec };
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
29
29
  const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
30
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
+ const goalSpecTask = require('../goal-spec-task.js'); // task 1004585 — a hall-made goal carries its own spec task (ADR 0366 D3)
32
33
  const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
33
34
  const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
34
35
  const goalForest = require('../goal-forest.js'); // task 1003480 — per-goal dependency forest
@@ -97,6 +98,7 @@ module.exports = function buildGoalsRouter() {
97
98
  // requested without stating a cause. minLength 8 because 'ok' / '.' is not
98
99
  // a reason, and the row is the only place the why survives.
99
100
  admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
101
+ spec_task: { type: 'boolean' }, // task 1004585 / ADR 0366 D3 — the hall form's opt-in (goal-spec-task.js)
100
102
  })) return;
101
103
 
102
104
  const scopeModules = req.body.scope_modules || [];
@@ -156,15 +158,11 @@ module.exports = function buildGoalsRouter() {
156
158
  const admission = versionDecision.overrideApplied
157
159
  ? { admittedBy: req.builder.id, reason: admitReason }
158
160
  : null;
159
- const goal = await db.createGoal({
160
- versionId: req.body.version_id,
161
- title: req.body.title,
161
+ const { goal, specTask } = await goalSpecTask.createGoalWithSpec({ db, withTx: api.withTx, withSpecTask: req.body.spec_task === true, goalFields: {
162
+ versionId: req.body.version_id, title: req.body.title,
162
163
  subtitle: (req.body.subtitle || '').trim() || null, description: (req.body.description || '').trim() || null,
163
- scopeModules,
164
- createdBy: req.builder.id,
165
- categoryId,
166
- admission,
167
- });
164
+ scopeModules, createdBy: req.builder.id, categoryId, admission,
165
+ } });
168
166
  // Stamp the predecessor's succeeded_by pointer at the new goal (the lineage).
169
167
  if (succeedsId != null) {
170
168
  await db.setGoalSuccessor({ goalId: succeedsId, successorId: goal.id });
@@ -182,7 +180,7 @@ module.exports = function buildGoalsRouter() {
182
180
  // waved into a building version, by whom and why — the caller should not
183
181
  // have to re-read the goal to find out whether their override took effect.
184
182
  const admitted = admission ? await db.getGoalAdmission(goal.id) : null;
185
- return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted });
183
+ return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory, admitted, spec_task: specTask });
186
184
  } catch (err) {
187
185
  log.error('[gds] POST /goals', err);
188
186
  return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
@@ -29,6 +29,7 @@ const { validateOrRespond, LIMITS, parseId } = api;
29
29
  const { badKind, detectsMigration } = require('./task-route-helpers.js');
30
30
  const { describeCheckViolation } = require('../pg-constraint-fail.js');
31
31
  const { goalNotOpenRefusal } = require('./goal-route-authz.js');
32
+ const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
32
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
33
34
  const log = api.logger('lifecycle');
34
35
 
@@ -189,10 +190,18 @@ module.exports = function registerTaskWriteRoutes(router) {
189
190
  // nobody reads. This is the human/agent door, and it always has a goal to
190
191
  // name — /builder-start lists work by goal, and POST /goals/:id/tasks has
191
192
  // required one since BV1.R60.
193
+ // task 1004597: the one exception is PLATFORM UPKEEP (a core upgrade, Bongos
194
+ // maintenance), which fits no product goal. An explicit kind 'infra' with no
195
+ // goal lands in the version's maintenance goal — see upkeep-home.js.
196
+ const upkeepHome = await resolveUpkeepHome(body, { ensureMaintenanceGoal: db.ensureMaintenanceGoal, withTx: api.withTx });
197
+ if (upkeepHome) body.goal_id = Number(upkeepHome.id);
192
198
  if (body.goal_id == null) {
193
199
  return res.fail('goal_id_required', {
194
200
  status: 400,
195
- message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
201
+ message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks. ' +
202
+ (isUpkeepFiling(body)
203
+ ? `Version '${body.version_id}' is not being built right now, so it has no maintenance goal for upkeep — file it against the version that is.`
204
+ : UPKEEP_HINT),
196
205
  });
197
206
  }
198
207
  if (body.goal_id != null) {
@@ -297,6 +306,7 @@ module.exports = function registerTaskWriteRoutes(router) {
297
306
  needs_migration: needsMigration,
298
307
  criteria,
299
308
  suggested_criteria,
309
+ ...(upkeepHome ? { goal_home: 'maintenance' } : {}), // task 1004597
300
310
  goal_advisory, dependency_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
301
311
  });
302
312
  } catch (err) {
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/upkeep-home.js — WHERE PLATFORM UPKEEP GOES WHEN NO PRODUCT
4
+ // GOAL FITS (task 1004597).
5
+ //
6
+ // THE CONTRADICTION THIS CLOSES. The ledger rule says record a change before you
7
+ // make it; the goal rule (ADR 0250 D4, BV1.R11) says every task belongs to a
8
+ // goal. A project's goals are product goals ("Marketing", "Live Floor"), so a
9
+ // builder about to upgrade the Bongos core had nowhere honest to file it: POST
10
+ // /tasks refused `goal_id_required`, and the only ways forward were to misfile
11
+ // the upgrade under a product goal or skip the record. One session (mercury,
12
+ // 2026-10-06) chose the latter.
13
+ //
14
+ // THE ANSWER REUSES THE HOMELESS-BUG HOME. Every building version already has a
15
+ // find-or-create MAINTENANCE goal (ADR 0262, `ensureMaintenanceGoal`): open, no
16
+ // scope wall, exempt from the version-close count, carried forward on close.
17
+ // Platform upkeep is maintenance in exactly that sense, so a task filed with
18
+ // NO goal and kind 'infra' lands there.
19
+ //
20
+ // ONLY AN EXPLICIT kind COUNTS — never the classifier's guess. The defect
21
+ // BV1.R11 fixed was SILENT bucketing (53 tasks filed out of sight by a default).
22
+ // A caller who writes kind 'infra' has said out loud what the work is; a feature
23
+ // whose text merely reads like infra must still name its goal.
24
+ //
25
+ // RANK AND APPROVAL ARE UNTOUCHED. This only answers "which goal"; the route's
26
+ // own permission check (task.create, Metic+) and status rules run exactly as for
27
+ // any other goal.
28
+
29
+ const UPKEEP_KIND = 'infra';
30
+
31
+ // isUpkeepFiling — true when a create names no goal and explicitly says it is
32
+ // platform upkeep. PURE; exported so the rule has one spelling.
33
+ function isUpkeepFiling(body) {
34
+ return !!body && body.goal_id == null && body.kind === UPKEEP_KIND;
35
+ }
36
+
37
+ // resolveUpkeepHome — the maintenance goal row an upkeep filing lands in, or
38
+ // null when it is not an upkeep filing or the version cannot take work (not
39
+ // `building`). The find-or-create runs inside its own transaction so the
40
+ // advisory lock in ensureMaintenanceGoal actually serialises concurrent filers
41
+ // (outside a transaction it would release at the end of its own statement).
42
+ async function resolveUpkeepHome(body, deps) {
43
+ if (!isUpkeepFiling(body)) return null;
44
+ const { ensureMaintenanceGoal, withTx } = deps;
45
+ return withTx((client) => ensureMaintenanceGoal(body.version_id, { client }));
46
+ }
47
+
48
+ // The plain-words half of the goal_id_required refusal: what to do when the work
49
+ // is upkeep rather than product work. Shared by the route and the CLI so the two
50
+ // never drift.
51
+ const UPKEEP_HINT =
52
+ 'Platform upkeep — a Bongos upgrade or other maintenance — has a standing home: ' +
53
+ `file it with kind "${UPKEEP_KIND}" and no goal, and it goes into this version's maintenance goal.`;
54
+
55
+ module.exports = { UPKEEP_KIND, UPKEEP_HINT, isUpkeepFiling, resolveUpkeepHome };
@@ -21,6 +21,15 @@
21
21
  // an absent rank as untrusted, so even the owner's own feedback waits for the nod
22
22
  // rather than inheriting a hub rank the instance never vouched for.
23
23
  //
24
+ // TWO DOORS IN (task 1004591). The relay above needs the project linked to the hub,
25
+ // which many are not, so the CLI now sends STRAIGHT here (POST /sso/feedback/direct)
26
+ // with a one-time receipt from its own project (src/bongos/routes/feedback.js).
27
+ // The hub answers at once, then redeems the receipt at the project's public URL:
28
+ // the project names the signed-in sender and the hash of the text, and only a
29
+ // matching report is filed. A project on the hub's own domain is one we run, so
30
+ // its word is verified; any other domain is printed as "confirmed by <host>".
31
+ // The relay stays for projects on cores older than the receipt.
32
+ //
24
33
  // THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
25
34
  // PROJECT is calling and nothing about WHO sent it, so the sender must hold a
26
35
  // membership on that project carrying the hub's own sign-in witness
@@ -86,8 +95,11 @@ function feedbackTitle(message) {
86
95
  return `Feedback: ${title}`;
87
96
  }
88
97
 
89
- function feedbackBody({ message, client, account, githubId, context = {} }) {
90
- const who = account && account.github_login ? `@${account.github_login}` : `GitHub user ${githubId}`;
98
+ function feedbackBody({ message, client, account, githubId, context = {}, vouch = null }) {
99
+ // vouch (direct reports only): { hosted, host, login } — the receipt's word.
100
+ const claimed = vouch && typeof vouch.login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(vouch.login) ? vouch.login : null;
101
+ const who = account && account.github_login ? `@${account.github_login}`
102
+ : claimed ? `@${claimed} (GitHub user ${githubId})` : `GitHub user ${githubId}`;
91
103
  const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
92
104
  const stamps = [
93
105
  ['Core', cleanStamp(context.core_version)],
@@ -97,10 +109,13 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
97
109
  // The hub's own attribution comes FIRST and the sender's words follow as a
98
110
  // quote, so text they type can never pose as the footer a reviewer trusts.
99
111
  const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
112
+ const vouchLine = !vouch ? []
113
+ : [`- Sender: ${vouch.hosted ? 'verified (a project Cloud Bongos hosts)' : `confirmed by ${vouch.host} (self-hosted, not verified by us)`}`];
100
114
  return [
101
- 'Sent from a managed project with `bongos feedback`.',
115
+ vouch ? 'Sent with `bongos feedback`.' : 'Sent from a managed project with `bongos feedback`.',
102
116
  `- Project: ${project}`,
103
117
  `- Sent by: ${who}`,
118
+ ...vouchLine,
104
119
  ...(stamps ? [`- Running: ${stamps}`] : []),
105
120
  '',
106
121
  quoted,
@@ -110,12 +125,15 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
110
125
  // fileInstanceFeedback — file one relayed report. `deps.capture` is the
111
126
  // `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
112
127
  // own rows for the sender. Returns the landing the CLI prints.
113
- async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
114
- const account = await deps.getAccount(githubId).catch(() => null);
115
- const capturedBy = await deps.hubBuilderId(githubId).catch(() => null);
128
+ async function fileInstanceFeedback({ client, githubId, message, kind, context, vouch = null }, deps) {
129
+ // A self-hosted project's word cannot borrow a hub account: its owner could
130
+ // name anyone's GitHub id, so the hub's login and builder row stay unread.
131
+ const trusted = !vouch || vouch.hosted;
132
+ const account = trusted ? await deps.getAccount(githubId).catch(() => null) : null;
133
+ const capturedBy = trusted ? await deps.hubBuilderId(githubId).catch(() => null) : null;
116
134
  const idea = await deps.capture({
117
135
  title: feedbackTitle(message),
118
- bodyMd: feedbackBody({ message, client, account, githubId, context }),
136
+ bodyMd: feedbackBody({ message, client, account, githubId, context, vouch }),
119
137
  kind: FEEDBACK_KINDS[kind],
120
138
  capturedBy,
121
139
  filerRank: null, // untrusted by design — see the header
@@ -129,7 +147,90 @@ async function fileInstanceFeedback({ client, githubId, message, kind, context }
129
147
  : { landed: 'inbox', idea_id: String(idea.id) };
130
148
  }
131
149
 
150
+ // ── the direct door (task 1004591) ───────────────────────────────────────────
151
+
152
+ const RECEIPT_TIMEOUT_MS = 8000;
153
+
154
+ function sha256Hex(text) {
155
+ return require('node:crypto').createHash('sha256').update(String(text), 'utf8').digest('hex');
156
+ }
157
+
158
+ // projectOrigin — the caller-named project, accepted only as a bare https origin
159
+ // on a DNS name (no IP literal, no port, no path, no userinfo). Null otherwise.
160
+ function projectOrigin(raw) {
161
+ let u;
162
+ try { u = new URL(String(raw || '')); } catch { return null; }
163
+ if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
164
+ if (u.pathname !== '/' && u.pathname !== '') return null;
165
+ if (u.search || u.hash) return null;
166
+ const host = u.hostname.toLowerCase();
167
+ if (!host.includes('.') || require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return null;
168
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
169
+ return { origin: `https://${host}`, host };
170
+ }
171
+
172
+ // isHostedBy — a project on the hub's own domain (or the hub itself) is one we run.
173
+ function isHostedBy(host, hubHost) {
174
+ const h = String(hubHost || '').toLowerCase();
175
+ return !!h && (host === h || host.endsWith(`.${h}`));
176
+ }
177
+
178
+ // verifyDirectReport — redeem the receipt at the project and check it matches.
179
+ // Resolves { ok: true, receipt } or { ok: false, reason }; never throws. The
180
+ // address is attacker-named, so the host must resolve to a public address
181
+ // (deps.assertPublicHost) and a redirect is an error, never followed.
182
+ async function verifyDirectReport({ origin, receipt, message, kind }, deps) {
183
+ const target = projectOrigin(origin);
184
+ if (!target) return { ok: false, reason: 'bad_origin' };
185
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(String(receipt || ''))) return { ok: false, reason: 'bad_receipt' };
186
+ try {
187
+ await deps.assertPublicHost(target.host);
188
+ } catch {
189
+ return { ok: false, reason: 'private_host' };
190
+ }
191
+ let body;
192
+ try {
193
+ const res = await (deps.fetchImpl || fetch)(`${target.origin}/api/bongos/feedback/receipt/${receipt}`, {
194
+ headers: { accept: 'application/json' },
195
+ redirect: 'error',
196
+ signal: AbortSignal.timeout(RECEIPT_TIMEOUT_MS),
197
+ });
198
+ if (res.status !== 200) return { ok: false, reason: `receipt_http_${res.status}` };
199
+ body = await res.json();
200
+ } catch {
201
+ return { ok: false, reason: 'receipt_unreachable' };
202
+ }
203
+ if (!body || typeof body !== 'object') return { ok: false, reason: 'receipt_malformed' };
204
+ if (!/^\d{1,20}$/.test(String(body.github_id || ''))) return { ok: false, reason: 'receipt_malformed' };
205
+ if (body.sha256 !== sha256Hex(message)) return { ok: false, reason: 'text_mismatch' };
206
+ if ((body.kind || 'bug') !== kind) return { ok: false, reason: 'kind_mismatch' };
207
+ return { ok: true, target, receipt: body };
208
+ }
209
+
210
+ // fileDirectReport — the whole after-the-202 half: verify, then file. Returns the
211
+ // landing, or { dropped: reason } for a report the project did not vouch for.
212
+ async function fileDirectReport({ origin, receipt, message, kind, context = {}, hubHost }, deps) {
213
+ const v = await verifyDirectReport({ origin, receipt, message, kind }, deps);
214
+ if (!v.ok) return { dropped: v.reason };
215
+ const githubId = String(v.receipt.github_id);
216
+ const hosted = isHostedBy(v.target.host, hubHost);
217
+ return fileInstanceFeedback({
218
+ client: { name: v.target.host, origin: v.target.origin, client_id: v.target.host },
219
+ githubId,
220
+ message,
221
+ kind,
222
+ // The project's own core version beats whatever the CLI claimed.
223
+ context: { ...context, core_version: v.receipt.core_version || null },
224
+ vouch: { hosted, host: v.target.host, login: v.receipt.github_login },
225
+ }, deps);
226
+ }
227
+
132
228
  module.exports = {
229
+ sha256Hex,
230
+ projectOrigin,
231
+ isHostedBy,
232
+ verifyDirectReport,
233
+ fileDirectReport,
133
234
  FEEDBACK_KINDS,
134
235
  MESSAGE_MAX,
135
236
  PER_SENDER_LIMIT,
@@ -30,9 +30,25 @@ const applicationEcho = require('../application-echo');
30
30
  // The guild label the echo carries (task 1002303, ADR 0365 D5).
31
31
  const guildEngagements = require('../guild-engagements');
32
32
  const instanceFeedback = require('../instance-feedback');
33
+ const projectStats = require('../project-stats');
33
34
 
34
35
  // One limiter per hub process for POST /sso/feedback (task 1004462).
35
36
  const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
37
+ // …and two for POST /sso/feedback/direct (task 1004591), which carries no
38
+ // credential: one keyed by the caller's address ALONE, so naming a fresh host on
39
+ // every request buys nothing, and one by the named project and address.
40
+ const directIpLimiter = instanceFeedback.makeFeedbackLimiter();
41
+ const directFeedbackLimiter = instanceFeedback.makeFeedbackLimiter();
42
+
43
+ // The hub's own host: a direct report from it or a subdomain of it comes from a
44
+ // project we run. Best-effort — no host means nothing counts as hosted. Read once.
45
+ let _hubHost = null;
46
+ function hubHost() {
47
+ if (_hubHost === null) {
48
+ try { _hubHost = new URL(api.branding().domains.publicOrigin).hostname.toLowerCase(); } catch { _hubHost = ''; }
49
+ }
50
+ return _hubHost;
51
+ }
36
52
 
37
53
  // Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
38
54
  // gate, not the gate itself (task 1002972 grade advisory). The echo already
@@ -781,5 +797,60 @@ module.exports = function ssoRoutes() {
781
797
  }
782
798
  });
783
799
 
800
+ // POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI,
801
+ // with a one-time receipt from their own project (task 1004591). Answers 202 at
802
+ // once so the builder never waits; the receipt is redeemed AFTER, and a report
803
+ // the project does not vouch for is dropped (logged, never filed). Why this
804
+ // proves the sender, and what "hosted" earns: ../instance-feedback.js.
805
+ //
806
+ // rank: public — no session on the hub; the proof is the project's receipt.
807
+ // Capped per caller address (10/h whatever host it names), then per named
808
+ // project (30/h), BEFORE anything is fetched.
809
+ router.post('/sso/feedback/direct', (req, res) => {
810
+ if (validateOrRespond(req, res, {
811
+ origin: { required: true, type: 'string', maxLength: 200 },
812
+ receipt: { required: true, type: 'string', maxLength: 64 },
813
+ message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
814
+ kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
815
+ context: {},
816
+ })) return;
817
+ const b = req.body || {};
818
+ const target = instanceFeedback.projectOrigin(b.origin);
819
+ if (!target) return res.fail('bad_origin', 400);
820
+ const message = String(b.message).trim();
821
+ if (!message) return res.fail('message_required', 400);
822
+ // The address's own bucket first (both of its tiers keyed by the address, so
823
+ // no bucket is shared between callers); only an address still under its cap
824
+ // may spend from the named project's bucket — a refused caller costs a real
825
+ // project nothing.
826
+ const ip = String(req.ip || 'unknown');
827
+ let allowed = directIpLimiter.take(ip, 'direct');
828
+ if (allowed.ok) allowed = directFeedbackLimiter.take(target.host, ip);
829
+ if (!allowed.ok) {
830
+ res.set('Retry-After', String(allowed.retryAfter));
831
+ return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
832
+ }
833
+ res.status(202).json({ ok: true, accepted: true });
834
+ const ctx = b.context && typeof b.context === 'object' ? b.context : {};
835
+ instanceFeedback.fileDirectReport({
836
+ origin: target.origin,
837
+ receipt: b.receipt,
838
+ message: b.message,
839
+ kind: b.kind || 'bug',
840
+ context: { cli_version: ctx.cli_version, os: ctx.os },
841
+ hubHost: hubHost(),
842
+ }, {
843
+ assertPublicHost: (host) => projectStats.assertPublicHost(host),
844
+ capture: (...a) => api.resolve('ideas.capture')(...a),
845
+ getAccount: (id) => pi.getAccount(id, { pool }),
846
+ hubBuilderId: async (id) => {
847
+ const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
848
+ return rows[0] ? rows[0].id : null;
849
+ },
850
+ }).then((r) => {
851
+ if (r && r.dropped) log.warn(`[platform-identity] /sso/feedback/direct dropped from ${target.host}: ${r.dropped}`);
852
+ }, (err) => log.error('[platform-identity] /sso/feedback/direct', err));
853
+ });
854
+
784
855
  return router;
785
856
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.69",
3
+ "version": "1.21.71",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.69",
9
+ "version": "1.21.71",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.69",
3
+ "version": "1.21.71",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9194,5 +9194,47 @@
9194
9194
  "id": "1004277",
9195
9195
  "text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
9196
9196
  }
9197
+ ],
9198
+ "1.21.70": [
9199
+ {
9200
+ "text": "Reporting a problem with Bongos now works from every project, including ones like Will's that could not send feedback before. It takes a second, needs no extra sign-in, and the Bongos team can see who really sent each report."
9201
+ }
9202
+ ],
9203
+ "1.21.71": [
9204
+ {
9205
+ "id": "1004595",
9206
+ "text": "A hosted project's Software update page no longer stops at \"Update ready\". When the people who run its server do the upgrade, the owner now sees who that is and an \"Ask for this upgrade\" button; one press reaches the Cloud Bon"
9207
+ },
9208
+ {
9209
+ "id": "1004621",
9210
+ "text": "The assistant in a Bongos project no longer starts every session with a failed command. Its instructions now say `npx bongos …`, which works as soon as the project is installed, instead of a bare `bongos` that the computer cou"
9211
+ },
9212
+ {
9213
+ "id": "1004586",
9214
+ "text": "The goal-planning assistant can now finish a goal that was started in the hall: it works out the goal's scope, its 'done when' checks and its first tasks, then releases the goal's work."
9215
+ },
9216
+ {
9217
+ "id": "1004622",
9218
+ "text": "Fixed a broken test that was stopping every new Bongos release from being published."
9219
+ },
9220
+ {
9221
+ "id": "1004596",
9222
+ "text": "When someone on a hosted project (like Will's) asks to upgrade from their own computer, Bongos now tells them plainly that the project's operator does upgrades, and how to ask for one — from the Software update page or with on"
9223
+ },
9224
+ {
9225
+ "id": "1004619",
9226
+ "text": "Bongos now has a written plan for red and blue security teams: attacks happen on a separate practice server, never on the live site, and every release gets checked there."
9227
+ },
9228
+ {
9229
+ "id": "1004597",
9230
+ "text": "Recording routine Bongos upkeep, such as a core upgrade, no longer gets refused just because none of a project's goals fit. Filed as upkeep with no goal, it now goes into the version's standing maintenance goal. Any other task"
9231
+ },
9232
+ {
9233
+ "id": "1004585",
9234
+ "text": "A goal created from the hall now arrives with a 'Spec goal N' task. Nothing else in that goal can be picked up until someone finishes it, so a goal never sits half-defined."
9235
+ },
9236
+ {
9237
+ "text": "Hosted projects like Will's now get a working Update button in Settings, instead of being told to run a command on a server they don't have access to."
9238
+ }
9197
9239
  ]
9198
9240
  }