@bongos/core 1.20.37 → 1.20.38

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 (50) hide show
  1. package/.bongos-core.json +93 -48
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/copy-inventory.md +9 -9
  20. package/docs/copy-registry.json +9 -9
  21. package/docs/file-map.md +1 -0
  22. package/docs/module-api-changelog.md +2 -0
  23. package/docs/page-readings.json +2 -2
  24. package/docs/recipes/upgrading-the-core.md +2 -0
  25. package/modules/autonomy/db.js +39 -8
  26. package/modules/autonomy/fence.js +26 -4
  27. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  28. package/modules/autonomy/routes/autonomy.js +28 -1
  29. package/modules/hall-ui/public/gate.js +33 -4
  30. package/modules/ideas/routing.js +8 -1
  31. package/modules/platform-identity/instance-feedback.js +142 -0
  32. package/modules/platform-identity/module.json +1 -1
  33. package/modules/platform-identity/routes/sso.js +62 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +20 -0
  37. package/scripts/gds/autobongos-run.js +14 -4
  38. package/scripts/gds/feedback-send.js +131 -0
  39. package/scripts/gds/update-channel.js +82 -0
  40. package/src/bongos/routes/feedback.js +110 -0
  41. package/src/bongos/routes.js +2 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/autobongos_fence.mjs +62 -0
  44. package/tests/autobongos_loop.mjs +76 -0
  45. package/tests/autonomy_fence_priority.mjs +211 -0
  46. package/tests/bongos_cli.mjs +7 -1
  47. package/tests/filing_abuse_matrix.mjs +7 -2
  48. package/tests/idea_routing.mjs +1 -1
  49. package/tests/instance_feedback.mjs +327 -0
  50. package/tests/update_channel_db.mjs +124 -0
@@ -0,0 +1,211 @@
1
+ // tests/autonomy_fence_priority.mjs — the fence's priority goal, server side
2
+ // (task 1004453).
3
+ //
4
+ // Owner ruling, 2026-09-30: whoever starts the runner names the goal it works
5
+ // FIRST, from the hall's /builders/gate page or the API, and the server refuses a
6
+ // priority that is not on the allowlist. These drive REAL requests through the
7
+ // assembled autonomy router with only the auth gates stubbed (the
8
+ // routine_observability_api precedent), pin the SQL against a stubbed pool, and
9
+ // hold the migration to the shape the route relies on. The pure ordering rule is
10
+ // tests/autobongos_fence.mjs; the runner's use of it is tests/autobongos_loop.mjs.
11
+ //
12
+ // DB-free, no network. Run: node --test tests/autonomy_fence_priority.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const req = createRequire(import.meta.url);
23
+ const express = req('express');
24
+ const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
25
+
26
+ const permStub = (key) => {
27
+ const mw = (_req, _res, next) => next();
28
+ mw.permKey = key;
29
+ return mw;
30
+ };
31
+ const passBuilder = (reqq, _res, next) => { reqq.builder = { id: 3, rank: 'archon' }; next(); };
32
+
33
+ function autonomyRouter() {
34
+ const api = req(path.join(ROOT, 'src/module-api.js'));
35
+ const saved = { b: api.requireBuilder, p: api.requirePermission };
36
+ api.requireBuilder = passBuilder;
37
+ api.requirePermission = permStub;
38
+ const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
39
+ delete req.cache[routePath];
40
+ try {
41
+ return req(routePath)();
42
+ } finally {
43
+ api.requireBuilder = saved.b;
44
+ api.requirePermission = saved.p;
45
+ }
46
+ }
47
+
48
+ async function serve(router) {
49
+ const app = express();
50
+ app.use(express.json());
51
+ app.use(attachFail);
52
+ app.use(router);
53
+ const server = app.listen(0);
54
+ await new Promise((r) => server.once('listening', r));
55
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
56
+ }
57
+
58
+ const post = (base, p, body) => fetch(`${base}${p}`, {
59
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
60
+ });
61
+
62
+ test('POST /autonomy/fence/priority sits behind the same owner-only atom as the allowlist', () => {
63
+ const router = autonomyRouter();
64
+ const layer = router.stack.find((l) => l.route && l.route.path === '/autonomy/fence/priority');
65
+ assert.ok(layer, 'the route is registered');
66
+ assert.equal(layer.route.methods.post, true);
67
+ assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
68
+ assert.equal(layer.route.stack[1].handle.permKey, 'autonomy.fence.manage');
69
+ });
70
+
71
+ test('the route sets, clears, and refuses a goal that is not allowlisted', async () => {
72
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
73
+ const saved = db.setPriorityGoal;
74
+ const seen = [];
75
+ let answer = { row: { priority_goal_id: 1000119, updated_at: '2026-09-30T00:00:00.000Z' } };
76
+ db.setPriorityGoal = async (args) => { seen.push(args); return answer; };
77
+ const s = await serve(autonomyRouter());
78
+ try {
79
+ let r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
80
+ assert.equal(r.status, 200);
81
+ assert.equal((await r.json()).priority_goal_id, 1000119);
82
+ assert.deepEqual(seen.pop(), { goalId: 1000119, builderId: 3 }, 'the writer is the SESSION, never the body');
83
+
84
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: '1000119' });
85
+ assert.equal(r.status, 200, 'a stringified id is accepted, as the allowlist route accepts one');
86
+ assert.equal(seen.pop().goalId, 1000119);
87
+
88
+ answer = { row: { priority_goal_id: null, updated_at: '2026-09-30T00:00:00.000Z' } };
89
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: null });
90
+ assert.equal(r.status, 200);
91
+ assert.equal(seen.pop().goalId, null, 'null clears the priority');
92
+
93
+ answer = { notAllowed: true };
94
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000090 });
95
+ assert.equal(r.status, 409, 'a priority must already be on the allowlist');
96
+ assert.equal((await r.json()).error.code, 'goal_not_allowlisted');
97
+
98
+ answer = { missing: true };
99
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
100
+ assert.equal(r.status, 503);
101
+
102
+ const before = seen.length;
103
+ for (const bad of [{}, { goal_id: 0 }, { goal_id: -1 }, { goal_id: 'abc' }, { goal_id: 1.5 }]) {
104
+ r = await post(s.base, '/autonomy/fence/priority', bad);
105
+ assert.equal(r.status, 400, `${JSON.stringify(bad)} is refused`);
106
+ }
107
+ assert.equal(seen.length, before, 'a malformed request never reaches the database');
108
+
109
+ db.setPriorityGoal = async () => { throw new Error('connection reset'); };
110
+ r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
111
+ assert.equal(r.status, 500);
112
+ assert.ok(!JSON.stringify(await r.json()).includes('connection reset'), 'the database error stays in the log');
113
+ } finally {
114
+ s.close();
115
+ db.setPriorityGoal = saved;
116
+ }
117
+ });
118
+
119
+ test('setPriorityGoal: the WHERE refuses a goal that is not allowlisted, and says which refusal it was', async () => {
120
+ const api = req(path.join(ROOT, 'src/module-api.js'));
121
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
122
+ const saved = api.pool.query;
123
+ const calls = [];
124
+ let replies = [];
125
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return replies.shift(); };
126
+ try {
127
+ replies = [{ rows: [{ priority_goal_id: '1000119', updated_at: 't' }] }];
128
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000119, builderId: 3 }), { row: { priority_goal_id: 1000119, updated_at: 't' } });
129
+ assert.match(calls[0].sql, /UPDATE autonomy_fence/);
130
+ assert.match(calls[0].sql, /EXISTS \(SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = \$1::bigint\)/);
131
+ assert.deepEqual(calls[0].params, [1000119, 3]);
132
+
133
+ replies = [{ rows: [] }, { rows: [{ '?column?': 1 }] }];
134
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { notAllowed: true });
135
+
136
+ replies = [{ rows: [] }, { rows: [] }];
137
+ assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { missing: true });
138
+
139
+ replies = [{ rows: [{ priority_goal_id: null, updated_at: 't' }] }];
140
+ assert.deepEqual(await db.setPriorityGoal({ goalId: null }), { row: { priority_goal_id: null, updated_at: 't' } });
141
+ } finally {
142
+ api.pool.query = saved;
143
+ }
144
+ });
145
+
146
+ test('readFence carries the priority as a number, and null when unset', async () => {
147
+ const api = req(path.join(ROOT, 'src/module-api.js'));
148
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
149
+ const saved = api.pool.query;
150
+ let pri = '1000119';
151
+ api.pool.query = async (sql) => (/FROM autonomy_fence/.test(sql)
152
+ ? { rows: [{ enabled: true, paused_reason: null, priority_goal_id: pri, updated_at: 't', updated_by: '3' }] }
153
+ : { rows: [{ goal_id: '1000119', note: null, added_at: 't', added_by: '3' }] });
154
+ try {
155
+ assert.equal((await db.readFence()).priority_goal_id, 1000119, 'a bigint string would never equal a numeric goal id');
156
+ pri = null;
157
+ assert.equal((await db.readFence()).priority_goal_id, null);
158
+ } finally {
159
+ api.pool.query = saved;
160
+ }
161
+ });
162
+
163
+ test('the heartbeat stores and returns the goal the current task came from', async () => {
164
+ const api = req(path.join(ROOT, 'src/module-api.js'));
165
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
166
+ const saved = api.pool.query;
167
+ const calls = [];
168
+ api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ working_task_id: 4242, working_goal_id: 1000090 }] }; };
169
+ try {
170
+ await db.recordRunnerHeartbeat({ builderId: 3, host: 'winbox', workingTaskId: 4242, workingGoalId: 1000090 });
171
+ assert.match(calls[0].sql, /working_goal_id = EXCLUDED\.working_goal_id/);
172
+ assert.equal(calls[0].params[8], 1000090);
173
+ const rows = await db.readRunnerHeartbeats(3);
174
+ assert.equal(rows[0].working_goal_id, '1000090');
175
+ } finally {
176
+ api.pool.query = saved;
177
+ }
178
+ });
179
+
180
+ test('the route accepts working_goal_id on a heartbeat and passes it through', async () => {
181
+ const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
182
+ const saved = db.recordRunnerHeartbeat;
183
+ const seen = [];
184
+ db.recordRunnerHeartbeat = async (args) => { seen.push(args); return {}; };
185
+ const s = await serve(autonomyRouter());
186
+ try {
187
+ const r = await post(s.base, '/autonomy/heartbeat', { host: 'winbox', working_task_id: 4242, working_goal_id: 1000090 });
188
+ assert.equal(r.status, 200);
189
+ assert.equal(seen[0].workingGoalId, 1000090);
190
+ } finally {
191
+ s.close();
192
+ db.recordRunnerHeartbeat = saved;
193
+ }
194
+ });
195
+
196
+ test('the migration ties the priority to the allowlist and clears it on revoke', () => {
197
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_003_priority_goal.sql'), 'utf8')
198
+ .split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
199
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS priority_goal_id bigint\s+REFERENCES autonomy_allowed_goals \(goal_id\) ON DELETE SET NULL/,
200
+ 'revoking the priority goal must clear the priority, never leave a revoked goal "first"');
201
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS working_goal_id bigint/);
202
+ assert.doesNotMatch(sql, /\bDROP\b/i, 'module migrations are additive');
203
+ });
204
+
205
+ test('the gate page renders the priority, lists it first, and posts to the priority route', () => {
206
+ const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
207
+ assert.match(src, /renderFenceGoals\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
208
+ assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
209
+ assert.match(src, /Make priority/);
210
+ assert.match(src, /Clear priority/);
211
+ });
@@ -87,7 +87,13 @@ test('VERBS: selfHelp is only claimed by scripts that really print their own usa
87
87
  // `serve` is dev.js with --server pinned on, so it inherits dev's own usage()
88
88
  // printer — verified: `bongos serve --help` prints the card and exits 0.
89
89
  const selfHelp = Object.entries(cli.VERBS).filter(([, d]) => d.selfHelp).map(([v]) => v).sort();
90
- assert.deepEqual(selfHelp, ['dev', 'module', 'serve', 'status', 'task', 'upgrade']);
90
+ assert.deepEqual(selfHelp, ['dev', 'feedback', 'module', 'serve', 'status', 'task', 'upgrade']);
91
+ });
92
+
93
+ test('bongos feedback --help prints its own usage and sends nothing', () => {
94
+ const r = run('feedback', '--help');
95
+ assert.equal(r.status, 0, r.stderr);
96
+ assert.match(r.stdout, /bongos feedback "what went wrong"/);
91
97
  });
92
98
 
93
99
  // ---- spawned: the dispatcher answers --help without touching the script ----
@@ -405,10 +405,15 @@ await test('[F18] the vector fails CLOSED — forged, absent or unknown never op
405
405
  `vector ${JSON.stringify(vector)} must not route`
406
406
  );
407
407
  }
408
- // The allowlist is exactly two members, and both are server-set at their call
408
+ // The allowlist is exactly three members, and all are server-set at their call
409
409
  // site — never read from a request body, which is what makes forging it a
410
410
  // non-move rather than a merely-hard one.
411
- assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs']);
411
+ assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs', 'instance-feedback']);
412
+ // The hub's feedback relay (task 1004462) names its own vector and never a rank,
413
+ // so a relayed bug can only ever land at 'backlog'.
414
+ const feedbackSrc = read('modules/platform-identity/instance-feedback.js');
415
+ assert.match(feedbackSrc, /vector: 'instance-feedback'/, 'the relay hardcodes its own vector');
416
+ assert.match(feedbackSrc, /filerRank: null/, 'and files with no rank');
412
417
  const routeSrc = read('modules/ideas/routes/inbox.js');
413
418
  assert.match(routeSrc, /vector: 'api'/, 'the route hardcodes its own vector');
414
419
  assert.ok(!/vector:\s*body\.|vector:\s*req\.body/.test(routeSrc),
@@ -172,7 +172,7 @@ await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-la
172
172
  // -------------------------------------------------------------------------
173
173
 
174
174
  await test('matrix: a homeless BUG from a trusted vector routes, and keeps the rank split', () => {
175
- for (const vector of ['api', 'discord-bugs']) {
175
+ for (const vector of ['api', 'discord-bugs', 'instance-feedback']) {
176
176
  assert.deepEqual(
177
177
  decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }),
178
178
  { route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' },
@@ -0,0 +1,327 @@
1
+ // tests/instance_feedback.mjs — `bongos feedback`: a builder on a managed project
2
+ // reports something about Bongos itself, their project relays it to the hub, and
3
+ // the hub files it (task 1004462). DB-free: the hub half is driven through its
4
+ // pure module with a fake `ideas.capture`, the project half through its real
5
+ // router with a fake hub.
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createRequire } from 'node:module';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const require_ = createRequire(import.meta.url);
16
+ const IF = require_(path.join(ROOT, 'modules/platform-identity/instance-feedback.js'));
17
+ const { decideLanding } = require_(path.join(ROOT, 'modules/ideas/routing.js'));
18
+
19
+ const CLIENT = { client_id: 'acme', name: 'Acme', origin: 'https://acme.example.com' };
20
+
21
+ // ── the hub: who gets to file, and where it lands ─────────────────────────────
22
+
23
+ test('a relayed bug lands as a homeless bug AWAITING A NOD, never claimable', () => {
24
+ // The owner's rule (2026-09-30): every report waits. The relay files with no
25
+ // rank, so no sender — the hub's own Archon included — can skip the nod.
26
+ const d = decideLanding({ goalId: null, kind: 'bug', filerRank: null, vector: 'instance-feedback' });
27
+ assert.deepEqual(d, { route: true, status: 'backlog', homeless: true, reason: 'bug_homeless_awaiting_nod' });
28
+ });
29
+
30
+ test('a relayed idea is not a defect — it stays in the inbox', () => {
31
+ const d = decideLanding({ goalId: null, kind: IF.FEEDBACK_KINDS.idea, filerRank: null, vector: 'instance-feedback' });
32
+ assert.equal(d.route, false);
33
+ });
34
+
35
+ test('fileInstanceFeedback files with its own vector, no rank, and the hub\'s own login', async () => {
36
+ let captured = null;
37
+ const landing = await IF.fileInstanceFeedback({
38
+ client: CLIENT, githubId: '42', message: 'claim hung\nfor two minutes', kind: 'bug',
39
+ context: { core_version: '1.19.900', cli_version: '0.2.0', os: 'darwin 24.6.0' },
40
+ }, {
41
+ capture: async (c) => { captured = c; return { id: 7, routing: { routed: true, task_id: 1234, goal_id: 1000118, status: 'backlog' } }; },
42
+ getAccount: async () => ({ github_login: 'hub-login' }),
43
+ hubBuilderId: async () => 99,
44
+ });
45
+ assert.equal(captured.vector, 'instance-feedback');
46
+ assert.equal(captured.filerRank, null);
47
+ assert.equal(captured.kind, 'bug');
48
+ assert.equal(captured.capturedBy, 99);
49
+ assert.equal(captured.title, 'Feedback: claim hung');
50
+ assert.match(captured.bodyMd, /Sent by: @hub-login/);
51
+ assert.match(captured.bodyMd, /Project: Acme \(https:\/\/acme\.example\.com\)/);
52
+ assert.match(captured.bodyMd, /Core 1\.19\.900 · CLI 0\.2\.0 · OS darwin 24\.6\.0/);
53
+ assert.deepEqual(landing, { landed: 'task', task_id: '1234', goal_id: '1000118', status: 'backlog', idea_id: '7' });
54
+ });
55
+
56
+ test('an unrouted filing reports the inbox, and an idea is filed as a feature', async () => {
57
+ let kind = null;
58
+ const landing = await IF.fileInstanceFeedback({ client: CLIENT, githubId: '42', message: 'wish', kind: 'idea' }, {
59
+ capture: async (c) => { kind = c.kind; return { id: 8, routing: { routed: false, reason: 'no_goal' } }; },
60
+ getAccount: async () => null,
61
+ hubBuilderId: async () => null,
62
+ });
63
+ assert.equal(kind, 'feature');
64
+ assert.deepEqual(landing, { landed: 'inbox', idea_id: '8' });
65
+ });
66
+
67
+ test('version stamps cannot carry markdown or links into the task', () => {
68
+ const body = IF.feedbackBody({
69
+ message: 'x', client: CLIENT, account: null, githubId: '42',
70
+ context: { core_version: '[click](https://evil.example)', cli_version: '0.2.0', os: 'linux\n- Sent by: @owner' },
71
+ });
72
+ assert.doesNotMatch(body, /evil/);
73
+ assert.doesNotMatch(body, /@owner/);
74
+ // The sender's own text is quoted below the attribution, so it cannot pose as it.
75
+ const spoof = IF.feedbackBody({ message: 'hi\n- Sent by: @owner', client: CLIENT, account: { github_login: 'real' }, githubId: '42' });
76
+ assert.ok(spoof.indexOf('- Sent by: @real') < spoof.indexOf('> - Sent by: @owner'));
77
+ assert.doesNotMatch(spoof, /^- Sent by: @owner/m);
78
+ assert.match(body, /CLI 0\.2\.0/);
79
+ assert.match(body, /Sent by: GitHub user 42/, 'no hub account → the id, never an instance-claimed login');
80
+ });
81
+
82
+ test('a long first line is trimmed to a title', () => {
83
+ const t = IF.feedbackTitle('a'.repeat(500));
84
+ assert.ok(t.length <= 'Feedback: '.length + 120);
85
+ assert.ok(t.endsWith('…'));
86
+ });
87
+
88
+ test('the per-sender cap stops one builder; the per-project cap stops one project', () => {
89
+ let now = 1_000_000;
90
+ const lim = IF.makeFeedbackLimiter({ now: () => now });
91
+ for (let i = 0; i < IF.PER_SENDER_LIMIT; i++) assert.equal(lim.take('acme', '1').ok, true);
92
+ const refused = lim.take('acme', '1');
93
+ assert.equal(refused.ok, false);
94
+ assert.equal(refused.scope, 'sender');
95
+ // A refusal spends nothing: the project still has exactly LIMIT - 10 left.
96
+ let sent = IF.PER_SENDER_LIMIT;
97
+ for (let s = 2; sent < IF.PER_PROJECT_LIMIT; s++) {
98
+ for (let i = 0; i < IF.PER_SENDER_LIMIT && sent < IF.PER_PROJECT_LIMIT; i++, sent++) {
99
+ assert.equal(lim.take('acme', String(s)).ok, true);
100
+ }
101
+ }
102
+ const projectRefused = lim.take('acme', '999');
103
+ assert.equal(projectRefused.ok, false);
104
+ assert.equal(projectRefused.scope, 'project');
105
+ assert.equal(lim.take('other', '1').ok, true, 'another project is untouched');
106
+ now += IF.FEEDBACK_WINDOW_MS;
107
+ assert.equal(lim.take('acme', '1').ok, true, 'the window slides');
108
+ });
109
+
110
+ test('the hub route authenticates the project and the sender BEFORE spending or filing', () => {
111
+ const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso.js'), 'utf8');
112
+ const route = src.slice(src.indexOf("router.post('/sso/feedback'"));
113
+ const at = (s) => { const i = route.indexOf(s); assert.ok(i > 0, `route has ${s}`); return i; };
114
+ assert.ok(at('clientSecretValid') < at('membershipBacksRollup'));
115
+ assert.ok(at('membershipBacksRollup') < at('feedbackLimiter.take'));
116
+ assert.ok(at('feedbackLimiter.take') < at('fileInstanceFeedback'));
117
+ assert.doesNotMatch(route.slice(0, route.indexOf('return router')), /filerRank|vector:/,
118
+ 'the route passes neither a rank nor a vector — both are fixed in instance-feedback.js');
119
+ });
120
+
121
+ // ── the project: the relay ────────────────────────────────────────────────────
122
+
123
+ async function withRoute({ idp, hub }, fn) {
124
+ const r2 = createRequire(import.meta.url);
125
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
126
+ const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/feedback.js'));
127
+ const envPath = r2.resolve(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
128
+ const realAuth = r2(authPath);
129
+ const saved = realAuth.requireBuilder;
130
+ realAuth.requireBuilder = (req, _res, next) => { req.builder = { id: 5, github_id: '42', github_login: 'someone' }; next(); };
131
+ delete r2.cache[routePath];
132
+ const sent = [];
133
+ const fetchImpl = async (url, opts) => {
134
+ sent.push({ url, body: JSON.parse(opts.body) });
135
+ const { status, body } = hub;
136
+ return { status, json: async () => body };
137
+ };
138
+ const express = r2('express');
139
+ const app = express();
140
+ app.use(express.json());
141
+ app.use(r2(envPath).attachFail);
142
+ app.use(r2(routePath)({ idpConfig: () => idp, fetchImpl }));
143
+ const server = app.listen(0);
144
+ await new Promise((res) => server.once('listening', res));
145
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/feedback`, {
146
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
147
+ }).then(async (r) => ({ status: r.status, body: await r.json() }));
148
+ try {
149
+ await fn({ post, sent });
150
+ } finally {
151
+ server.close();
152
+ realAuth.requireBuilder = saved;
153
+ delete r2.cache[routePath];
154
+ }
155
+ }
156
+
157
+ const IDP = { origin: 'https://hub.example.com', clientId: 'acme', clientSecret: 's3cret' };
158
+
159
+ test('the relay sends the SESSION\'s identity and the running core to the hub, and returns the landing', async () => {
160
+ await withRoute({ idp: IDP, hub: { status: 201, body: { ok: true, landed: 'task', task_id: '1234', status: 'backlog' } } }, async ({ post, sent }) => {
161
+ assert.equal((await post({ message: 'x', github_id: '1' })).status, 400, 'a body naming a sender is refused outright');
162
+ assert.equal(sent.length, 0);
163
+ const r = await post({ message: ' it broke ', context: { cli_version: '0.2.0', os: 'darwin', core_version: 'forged' } });
164
+ assert.equal(r.status, 201);
165
+ assert.equal(r.body.task_id, '1234');
166
+ assert.equal(r.body.hub_origin, IDP.origin);
167
+ assert.equal(sent.length, 1);
168
+ assert.equal(sent[0].url, 'https://hub.example.com/api/bongos/sso/feedback');
169
+ assert.equal(sent[0].body.github_id, '42', 'from the session, never the body');
170
+ assert.equal(sent[0].body.message, 'it broke');
171
+ assert.equal(sent[0].body.kind, 'bug');
172
+ assert.notEqual(sent[0].body.context.core_version, 'forged', 'the core version is this server\'s own');
173
+ assert.equal(sent[0].body.client_secret, 's3cret');
174
+ });
175
+ });
176
+
177
+ test('a project with no hub link says so, and sends nothing', async () => {
178
+ await withRoute({ idp: null, hub: { status: 500, body: {} } }, async ({ post, sent }) => {
179
+ const r = await post({ message: 'hello' });
180
+ assert.equal(r.status, 409);
181
+ assert.equal(r.body.error.code, 'not_hub_connected');
182
+ assert.equal(sent.length, 0);
183
+ });
184
+ });
185
+
186
+ test('an empty message or an unknown kind is refused locally', async () => {
187
+ await withRoute({ idp: IDP, hub: { status: 201, body: { ok: true } } }, async ({ post, sent }) => {
188
+ assert.equal((await post({ message: ' ' })).body.error.code, 'message_required');
189
+ const badKind = await post({ message: 'x', kind: 'task' });
190
+ assert.equal(badKind.status, 400);
191
+ assert.equal((await post({ message: 'x'.repeat(4001) })).status, 400, 'over the cap');
192
+ assert.equal(sent.length, 0);
193
+ });
194
+ });
195
+
196
+ test('a hub refusal the builder can act on is passed through; anything else is generic', async () => {
197
+ await withRoute({ idp: IDP, hub: { status: 429, body: { error: { code: 'rate_limited', details: { retry_after_seconds: 600 } } } } }, async ({ post }) => {
198
+ const r = await post({ message: 'x' });
199
+ assert.equal(r.status, 429);
200
+ assert.equal(r.body.error.code, 'rate_limited');
201
+ assert.equal(r.body.error.details.retry_after_seconds, 600);
202
+ });
203
+ await withRoute({ idp: IDP, hub: { status: 401, body: { error: { code: 'invalid_client', message: 'secret mismatch' } } } }, async ({ post }) => {
204
+ const r = await post({ message: 'x' });
205
+ assert.equal(r.status, 502);
206
+ assert.equal(r.body.error.code, 'hub_refused');
207
+ assert.doesNotMatch(JSON.stringify(r.body), /secret/);
208
+ });
209
+ });
210
+
211
+ // ── the CLI ───────────────────────────────────────────────────────────────────
212
+
213
+ test('`bongos feedback` is a registered verb, and every refusal code has a plain line', () => {
214
+ const { VERBS } = require_(path.join(ROOT, 'bin/bongos.js'));
215
+ assert.equal(VERBS.feedback.script, 'feedback-send.js');
216
+ const { REFUSALS, readMessage } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
217
+ for (const code of ['message_required', 'not_hub_connected', 'membership_required', 'rate_limited', 'feedback_unavailable', 'hub_unreachable']) {
218
+ assert.ok(REFUSALS[code], code);
219
+ }
220
+ for (const code of ['validation_failed']) {
221
+ assert.ok(REFUSALS[code], code);
222
+ }
223
+ assert.equal(readMessage(['--idea', 'filter', 'by', 'goal']), 'filter by goal');
224
+ });
225
+
226
+ // ── the hub route, driven for real ────────────────────────────────────────────
227
+ // The assembled /sso router with its DB edges stubbed: the client lookup, the
228
+ // membership read and the account read on the platform-identity domain, the
229
+ // builders lookup on the doorway's pool, and the `ideas.capture` port.
230
+
231
+ async function withHub(overrides, fn) {
232
+ const r2 = createRequire(import.meta.url);
233
+ const api = r2(path.join(ROOT, 'src/module-api.js'));
234
+ const seams = r2(path.join(ROOT, 'src/module-seams.js'));
235
+ const pi = r2(path.join(ROOT, 'modules/platform-identity/platform-identity.js'));
236
+ const saved = { ...pi };
237
+ const savedQuery = api.pool.query;
238
+ const captured = [];
239
+ Object.assign(pi, {
240
+ getClient: async (id) => (id === 'acme' ? { ...CLIENT, status: 'active' } : null),
241
+ clientSecretValid: (_c, s) => s === 'right',
242
+ getMembership: async () => ({ membership_kind: 'member', signin_verified_at: new Date() }),
243
+ getAccount: async () => ({ github_login: 'hub-login' }),
244
+ }, overrides);
245
+ api.pool.query = async () => ({ rows: [] });
246
+ if (!seams.hasProvider('ideas.capture')) {
247
+ seams.registerProvider('ideas.capture', async (c) => { captured.push(c); return { id: 1, routing: { routed: true, task_id: 55, goal_id: 9, status: 'backlog' } }; });
248
+ }
249
+ const routerPath = r2.resolve(path.join(ROOT, 'modules/platform-identity/routes/sso.js'));
250
+ delete r2.cache[routerPath];
251
+ const express = r2('express');
252
+ const app = express();
253
+ app.use(express.json());
254
+ app.use(r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js')).attachFail);
255
+ app.use(r2(routerPath)());
256
+ const server = app.listen(0);
257
+ await new Promise((res) => server.once('listening', res));
258
+ const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/sso/feedback`, {
259
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
260
+ }).then(async (res) => ({ status: res.status, body: await res.json() }));
261
+ try { await fn({ post, captured }); } finally {
262
+ server.close();
263
+ Object.assign(pi, saved);
264
+ api.pool.query = savedQuery;
265
+ delete r2.cache[routerPath];
266
+ }
267
+ }
268
+
269
+ const GOOD = { client_id: 'acme', client_secret: 'right', github_id: '42', message: 'claim hung' };
270
+
271
+ test('hub: a witnessed member\'s bug is filed at backlog and the landing comes back 201', async () => {
272
+ await withHub({}, async ({ post, captured }) => {
273
+ const r = await post(GOOD);
274
+ assert.equal(r.status, 201, JSON.stringify(r.body));
275
+ assert.deepEqual(r.body, { ok: true, landed: 'task', task_id: '55', goal_id: '9', status: 'backlog', idea_id: '1' });
276
+ assert.equal(captured.at(-1).vector, 'instance-feedback');
277
+ assert.equal(captured.at(-1).filerRank, null);
278
+ });
279
+ });
280
+
281
+ test('hub: a wrong secret, an unwitnessed sender and a forged field are all refused before filing', async () => {
282
+ await withHub({}, async ({ post, captured }) => {
283
+ const before = captured.length;
284
+ assert.equal((await post({ ...GOOD, client_secret: 'wrong' })).status, 401);
285
+ assert.equal((await post({ ...GOOD, github_id: 'abc' })).status, 400);
286
+ assert.equal((await post({ ...GOOD, filer_rank: 'archon' })).status, 400, 'no rank can ride the body');
287
+ assert.equal(captured.length, before);
288
+ });
289
+ await withHub({ getMembership: async () => ({ membership_kind: 'member', signin_verified_at: null }) }, async ({ post, captured }) => {
290
+ const before = captured.length;
291
+ const r = await post(GOOD);
292
+ assert.equal(r.status, 409);
293
+ assert.equal(r.body.error.code, 'membership_required');
294
+ assert.equal(captured.length, before);
295
+ });
296
+ });
297
+
298
+ // ── proactive filing (task 1004467) ───────────────────────────────────────────
299
+ // The owner's rule: a builder venting that Bongos is broken gets it filed without
300
+ // asking. That lives in two places, and both must keep saying it.
301
+
302
+ test('the skill files unasked on a Bongos rant, and the kernel tells every session to', () => {
303
+ const skill = fs.readFileSync(path.join(ROOT, '.claude/skills/bongos-feedback/SKILL.md'), 'utf8');
304
+ const description = skill.split('\n---')[0];
305
+ assert.match(description, /unasked/, 'the listing entry itself says it acts unprompted');
306
+ assert.match(description, /Bongos is broken/, 'a venting phrase is a trigger');
307
+ assert.match(skill, /File it without being asked/);
308
+ assert.match(skill, /Never paste the rant/, 'a report, not the raw complaint');
309
+ assert.match(skill, /own project/, 'their own code is out of scope');
310
+ assert.match(skill, /One filing per distinct problem per session/);
311
+ const kernel = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8');
312
+ assert.match(kernel, /vents that Bongos itself is broken.*\/bongos-feedback/);
313
+ });
314
+
315
+ test('every report is secret-scrubbed before it leaves the machine, and never sent unscrubbed', async () => {
316
+ const { scrubReport } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
317
+ const token = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8';
318
+ const envValue = 'zq9-private-value-7781';
319
+ const r = scrubReport(`claim failed, my token is ${token} and the key is ${envValue}`, { MY_API_KEY: envValue });
320
+ assert.doesNotMatch(r.text, /ghp_/);
321
+ assert.ok(!r.text.includes(envValue), 'an env secret is stripped by value');
322
+ assert.ok(r.removed >= 2);
323
+ assert.equal(scrubReport('bongos claim hung for two minutes', {}).text, 'bongos claim hung for two minutes', 'plain prose is untouched');
324
+ const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/feedback-send.js'), 'utf8');
325
+ assert.ok(src.indexOf('scrubReport(message)') < src.indexOf("api.request('POST', '/feedback'"), 'scrubbed before the request');
326
+ assert.match(src, /message: scrubbed\.text/, 'only the scrubbed text is sent');
327
+ });