@bongos/core 1.21.68 → 1.21.70

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 (32) hide show
  1. package/.bongos-core.json +36 -36
  2. package/.claude/skills/bongos-feedback/SKILL.md +10 -46
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +6 -2
  5. package/clients/bongos-client/index.cjs +6 -2
  6. package/clients/bongos-client/index.d.ts +10 -3
  7. package/clients/bongos-client/index.mjs +6 -2
  8. package/docs/api/openapi.json +151 -15
  9. package/docs/api-reference.md +6 -4
  10. package/docs/copy-inventory.md +371 -366
  11. package/docs/copy-registry.json +457 -412
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +2 -1
  14. package/docs/page-readings.json +718 -714
  15. package/modules/platform-identity/instance-feedback.js +108 -7
  16. package/modules/platform-identity/routes/sso.js +71 -0
  17. package/modules/public-landing/public/account.html +88 -1
  18. package/modules/public-landing/public/account.probes.json +9 -0
  19. package/modules/public-landing/public/account.states.json +2 -1
  20. package/modules/public-landing/public/assets/cosmos.css +3 -0
  21. package/modules/public-landing/public/projects.html +14 -81
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/release-notes.json +11 -0
  25. package/scripts/gds/feedback-send.js +144 -71
  26. package/src/bongos/platform-visibility-gate.js +3 -0
  27. package/src/bongos/routes/feedback.js +91 -77
  28. package/src/module-api.js +1 -1
  29. package/tests/card_apply_ui.mjs +0 -1
  30. package/tests/hub_account_page.mjs +132 -6
  31. package/tests/instance_feedback.mjs +267 -68
  32. package/tests/public_landing_projects.mjs +12 -13
@@ -81,7 +81,7 @@ test('the script builds no markup from strings, and the controls are real markup
81
81
  test('the door returns here, and the page calls only its own-scoped routes', () => {
82
82
  assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
83
83
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
84
- assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
84
+ assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/'].sort());
85
85
  // the bar's two calls live in the shared asset: the identity read and sign out
86
86
  const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
87
87
  assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
@@ -180,14 +180,27 @@ const ACCOUNT = () => ({
180
180
  function boot(answer) {
181
181
  const els = new Map();
182
182
  const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
183
- const el = (id, hidden, attrs) => listeners({
184
- id, hidden, attrs, value: '', textContent: '', className: '', disabled: false, style: {},
185
- appendChild(c) { this.textContent += c.textContent; return c; },
183
+ // textContent reads the node's own text plus its children's, and setting it
184
+ // drops the children, as in the browser — so a list can be built, read and
185
+ // emptied (the projects you build on, task 1004277)
186
+ const withChildren = (o) => {
187
+ o.children = [];
188
+ o._text = '';
189
+ Object.defineProperty(o, 'textContent', {
190
+ get() { return o._text + o.children.map((c) => c.textContent).join(''); },
191
+ set(v) { o._text = String(v); o.children = []; },
192
+ });
193
+ o.appendChild = (c) => { c.parent = o; o.children.push(c); return c; };
194
+ o.remove = () => { if (o.parent) o.parent.children = o.parent.children.filter((c) => c !== o); };
195
+ return o;
196
+ };
197
+ const el = (id, hidden, attrs) => withChildren(listeners({
198
+ id, hidden, attrs, value: '', className: '', disabled: false, style: {},
186
199
  getAttribute(n) { return n in attrs ? attrs[n] : null; },
187
200
  setAttribute(n, v) { attrs[n] = v; },
188
201
  closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
189
202
  focus() { dom.focused = id; document.activeElement = this; },
190
- });
203
+ }));
191
204
  for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
192
205
  const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
193
206
  els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
@@ -262,7 +275,10 @@ function boot(answer) {
262
275
  if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
263
276
  return radios.filter((r) => names.includes(r.name));
264
277
  },
265
- createElement: () => ({ className: '', textContent: '' }),
278
+ createElement: (tag) => {
279
+ const attrs = {};
280
+ return withChildren(listeners({ tag, attrs, className: '', disabled: false, setAttribute(n, v) { attrs[n] = String(v); } }));
281
+ },
266
282
  };
267
283
  const fetch = (url, init = {}) => {
268
284
  const method = init.method || 'GET';
@@ -440,6 +456,116 @@ test('a session that expired mid-edit reopens the door instead of failing silent
440
456
  assert.equal(dom.els.get('signinTop').hidden, false);
441
457
  });
442
458
 
459
+ // ── 3a. the projects you build on (task 1004277) ─────────────────────────────
460
+ // Moved here from /projects?view=mine (task 1003774's list and Leave): the owner
461
+ // put it on the builder's own profile, own view only, and with no hub profile
462
+ // page built, /account is that view (the owner's pick, 2026-10-06).
463
+
464
+ const BUILDING = [
465
+ { client_id: 'otb', name: 'Example World', origin: 'https://www.example.com/' },
466
+ { client_id: 'a b', name: null, origin: 'https://emersonian.cloudbongos.com' },
467
+ ];
468
+ const building = (rows, leave = () => ({ status: 200, data: { ok: true, left: true } })) => (m, p) => {
469
+ if (m === 'GET' && p === '/me/cross-project') return { status: 200, data: ACCOUNT() };
470
+ if (m === 'GET' && p === '/my-projects') return rows ? { status: 200, data: { owned: [], building: rows, applied: [] } } : { status: 500, data: null };
471
+ if (m === 'POST' && /^\/my-projects\/[^/]+\/leave$/.test(p)) return leave(p);
472
+ return { status: 500, data: null };
473
+ };
474
+ const rowsOf = (dom) => dom.els.get('buildingList').children;
475
+ const leaveOf = (row) => row.children[1].children[0];
476
+
477
+ test('the section ships inside the signed-in body, last, as a list with an empty line and a status line', () => {
478
+ const body = PAGE.split('<div id="acctBody" hidden>')[1].split('</main>')[0];
479
+ const sec = (body.match(/<section class="aSec" id="aBuilding" aria-labelledby="aBuilding-h">[\s\S]*?<\/section>/) || [''])[0];
480
+ assert.ok(sec, 'the projects you build on are a section of the account body');
481
+ assert.ok(body.indexOf('id="aBuilding"') > body.indexOf('id="aRecord"'), 'after your record, which counts them');
482
+ assert.match(sec, /<h2 id="aBuilding-h">projects you build on<\/h2>/);
483
+ assert.match(sec, /<p class="aWhat" id="buildingEmpty" hidden>/, 'the empty line waits for a read that says so');
484
+ assert.match(sec, /<ul class="mineRows" id="buildingList"><\/ul>/, 'the rows are cosmos.css’s, as on /invitations');
485
+ assert.match(sec, /<p class="projMsg" id="buildingMsg" role="status" aria-live="polite"><\/p>/);
486
+ });
487
+
488
+ test('signed in: the list is read once, after the account, and each row names its project and host with its own Leave', async () => {
489
+ const dom = boot(building(BUILDING));
490
+ await settle();
491
+ assert.deepEqual(dom.calls.filter((c) => c.method === 'GET' && c.path !== '/me').map((c) => c.path), ['/me/cross-project', '/my-projects']);
492
+ assert.equal(dom.els.get('buildingLoading').hidden, true);
493
+ assert.equal(dom.els.get('buildingEmpty').hidden, true);
494
+ const rows = rowsOf(dom);
495
+ assert.equal(rows.length, 2);
496
+ assert.equal(rows[0].className, 'mineRow');
497
+ assert.equal(rows[0].children[0].children[0].textContent, 'Example World');
498
+ assert.equal(rows[0].children[0].children[1].textContent, 'example.com', 'the host, without scheme, www or path');
499
+ assert.equal(rows[1].children[0].children[0].textContent, 'a b', 'no name: the client id stands in');
500
+ assert.equal(leaveOf(rows[0]).textContent, 'Leave');
501
+ assert.equal(leaveOf(rows[0]).attrs['aria-label'], 'Leave Example World', 'every button reads Leave; its name says which project');
502
+ assert.equal(leaveOf(rows[0]).className, 'btn small ghost');
503
+ });
504
+
505
+ test('nothing built on: the empty line shows; a failed read says so and never claims the list is empty', async () => {
506
+ const none = boot(building([]));
507
+ await settle();
508
+ assert.equal(rowsOf(none).length, 0);
509
+ assert.equal(none.els.get('buildingEmpty').hidden, false);
510
+ assert.match(PAGE, /id="buildingEmpty" hidden>You’re not building on any other projects yet\.<\/p>/);
511
+
512
+ const failed = boot(building(null));
513
+ await settle();
514
+ assert.equal(failed.els.get('buildingEmpty').hidden, true, 'a failed read is not "nothing"');
515
+ assert.equal(failed.els.get('buildingLoading').hidden, true);
516
+ assert.equal(failed.text('buildingMsg'), 'Couldn’t read the projects you build on — refresh in a moment.');
517
+ assert.equal(failed.els.get('buildingMsg').className, 'projMsg err');
518
+ });
519
+
520
+ test('Leave posts to the project’s own client id, says so while it goes, drops the row, and the last one shows the empty line', async () => {
521
+ const dom = boot(building(BUILDING));
522
+ await settle();
523
+ const [first] = rowsOf(dom);
524
+ dom.click(leaveOf(first));
525
+ assert.equal(leaveOf(first).disabled, true);
526
+ assert.equal(leaveOf(first).textContent, 'Leaving…');
527
+ await settle();
528
+ assert.equal(dom.calls.filter((c) => c.method === 'POST')[0].path, '/my-projects/otb/leave');
529
+ assert.equal(rowsOf(dom).length, 1, 'the row is gone once the server says so');
530
+ assert.equal(dom.text('buildingMsg'), 'You left Example World.');
531
+ assert.equal(dom.els.get('buildingMsg').className, 'projMsg ok');
532
+ assert.equal(dom.els.get('buildingEmpty').hidden, true, 'one left still');
533
+
534
+ dom.click(leaveOf(rowsOf(dom)[0]));
535
+ await settle();
536
+ assert.equal(dom.calls.filter((c) => c.method === 'POST')[1].path, '/my-projects/a%20b/leave', 'the id is encoded, never spliced raw');
537
+ assert.equal(rowsOf(dom).length, 0);
538
+ assert.equal(dom.els.get('buildingEmpty').hidden, false, 'and with the last gone, the empty line');
539
+ });
540
+
541
+ test('a refused Leave keeps the row and its button, and an expired session opens the door', async () => {
542
+ const refused = boot(building(BUILDING, () => ({ status: 500, data: { error: { code: 'leave_failed', message: 'internal error' } } })));
543
+ await settle();
544
+ const row = rowsOf(refused)[0];
545
+ refused.click(leaveOf(row));
546
+ await settle();
547
+ assert.equal(rowsOf(refused).length, 2);
548
+ assert.equal(leaveOf(row).disabled, false);
549
+ assert.equal(leaveOf(row).textContent, 'Leave');
550
+ assert.equal(refused.text('buildingMsg'), 'Couldn’t leave Example World. Try again.', 'plain words, not the server’s "internal error"');
551
+ assert.equal(refused.els.get('buildingMsg').className, 'projMsg err');
552
+
553
+ const expired = boot(building(BUILDING, () => ({ status: 401, data: null })));
554
+ await settle();
555
+ expired.click(leaveOf(rowsOf(expired)[0]));
556
+ await settle();
557
+ assert.equal(rowsOf(expired).length, 2);
558
+ assert.equal(expired.els.get('door').hidden, false);
559
+ assert.equal(expired.els.get('acctBody').hidden, true);
560
+ });
561
+
562
+ test('/projects keeps no copy: the mine view points here, and nothing there reads or leaves the list', () => {
563
+ assert.doesNotMatch(HUB, /id="buildingList"|renderBuildingList|\/leave'/, 'the list and Leave moved here whole');
564
+ assert.match(HUB, /<h2 id="mineToAccountH">Projects you build on<\/h2>\s*<p class="lede">[^<]*<a href="\/account#aBuilding">Open your account<\/a><\/p>/, 'the mine view says where they went');
565
+ assert.match(HUB, /\(d\.building \|\| \[\]\)\.forEach\(function \(m\) \{ if \(m\.origin\) mineOrigins\[hostOf\(m\.origin\)\] = 'builder'; \}\);/, 'the map still reads who you build with');
566
+ assert.match(SCRIPT, /if \(target && typeof target\.scrollIntoView === 'function'\) target\.scrollIntoView\(\);/, 'and #aBuilding lands on the section once the body shows');
567
+ });
568
+
443
569
  // ── 3b. the bar, executed (task 1004455) ─────────────────────────────────────
444
570
 
445
571
  const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
@@ -1,6 +1,6 @@
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
1
+ // tests/instance_feedback.mjs — `bongos feedback`: a builder reports something
2
+ // about Bongos itself straight to the hub, their project vouches for who sent it
3
+ // with a one-time receipt, and the hub files it (tasks 1004462, 1004591). DB-free: the hub half is driven through its
4
4
  // pure module with a fake `ideas.capture`, the project half through its real
5
5
  // router with a fake hub.
6
6
 
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
8
8
  import { test } from 'node:test';
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import os from 'node:os';
11
12
  import { createRequire } from 'node:module';
12
13
  import { fileURLToPath } from 'node:url';
13
14
 
@@ -118,9 +119,12 @@ test('the hub route authenticates the project and the sender BEFORE spending or
118
119
  'the route passes neither a rank nor a vector — both are fixed in instance-feedback.js');
119
120
  });
120
121
 
121
- // ── the project: the relay ────────────────────────────────────────────────────
122
+ // ── the project: the receipt (task 1004591) ──────────────────────────────────
123
+ // The project no longer relays anything. It vouches: a signed-in builder asks
124
+ // for a one-time receipt naming them and the hash of the text, and the hub
125
+ // redeems it once at the project's public URL.
122
126
 
123
- async function withRoute({ idp, hub }, fn) {
127
+ async function withRoute(opts, fn) {
124
128
  const r2 = createRequire(import.meta.url);
125
129
  const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
126
130
  const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/feedback.js'));
@@ -129,24 +133,21 @@ async function withRoute({ idp, hub }, fn) {
129
133
  const saved = realAuth.requireBuilder;
130
134
  realAuth.requireBuilder = (req, _res, next) => { req.builder = { id: 5, github_id: '42', github_login: 'someone' }; next(); };
131
135
  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
- };
136
+ const build = r2(routePath);
138
137
  const express = r2('express');
139
138
  const app = express();
140
139
  app.use(express.json());
141
140
  app.use(r2(envPath).attachFail);
142
- app.use(r2(routePath)({ idpConfig: () => idp, fetchImpl }));
141
+ app.use(build({ hub: () => 'https://hub.example.com', ...opts }));
143
142
  const server = app.listen(0);
144
143
  await new Promise((res) => server.once('listening', res));
145
- const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/feedback`, {
144
+ const url = (p) => `http://127.0.0.1:${server.address().port}${p}`;
145
+ const post = (body) => fetch(url('/feedback/receipt'), {
146
146
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
147
147
  }).then(async (r) => ({ status: r.status, body: await r.json() }));
148
+ const redeem = (id) => fetch(url(`/feedback/receipt/${id}`)).then(async (r) => ({ status: r.status, body: await r.json() }));
148
149
  try {
149
- await fn({ post, sent });
150
+ await fn({ post, redeem, build });
150
151
  } finally {
151
152
  server.close();
152
153
  realAuth.requireBuilder = saved;
@@ -154,75 +155,232 @@ async function withRoute({ idp, hub }, fn) {
154
155
  }
155
156
  }
156
157
 
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');
158
+ const HASH = IF.sha256Hex('it broke');
159
+
160
+ test('a receipt names the SESSION\'s builder and the text hash, and redeems exactly once', async () => {
161
+ await withRoute({}, async ({ post, redeem }) => {
162
+ const r = await post({ sha256: HASH, kind: 'idea' });
163
+ assert.equal(r.status, 201, JSON.stringify(r.body));
164
+ assert.equal(r.body.hub, 'https://hub.example.com');
165
+ assert.match(r.body.receipt, /^[A-Za-z0-9_-]{24}$/);
166
+ const first = await redeem(r.body.receipt);
167
+ assert.equal(first.status, 200);
168
+ assert.equal(first.body.github_id, '42', 'from the session, never the body');
169
+ assert.equal(first.body.github_login, 'someone');
170
+ assert.equal(first.body.sha256, HASH);
171
+ assert.equal(first.body.kind, 'idea');
172
+ assert.equal((await redeem(r.body.receipt)).status, 404, 'single use');
173
+ assert.equal((await redeem('not-a-real-receipt-id')).status, 404);
174
174
  });
175
175
  });
176
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);
177
+ test('a receipt request with a body naming a sender, or no hash, is refused', async () => {
178
+ await withRoute({}, async ({ post }) => {
179
+ assert.equal((await post({ sha256: HASH, github_id: '1' })).status, 400, 'no sender rides the body');
180
+ assert.equal((await post({ sha256: 'abc' })).status, 400);
181
+ assert.equal((await post({ sha256: HASH, kind: 'task' })).status, 400);
183
182
  });
184
183
  });
185
184
 
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
- });
185
+ test('receipts expire and the store is capped', () => {
186
+ const { makeReceiptStore } = require_(path.join(ROOT, 'src/bongos/routes/feedback.js'));
187
+ let now = 0;
188
+ const store = makeReceiptStore({ now: () => now, ttlMs: 1000, max: 3 });
189
+ const id = store.issue({ github_id: '1' });
190
+ now = 1001;
191
+ assert.equal(store.redeem(id), null, 'expired');
192
+ const live = [0, 1, 2].map((i) => store.issue({ github_id: String(i) }));
193
+ assert.equal(store.issue({ github_id: 'noisy' }), null, 'a full store refuses the new one');
194
+ assert.equal(store._rows.size, 3);
195
+ assert.equal(store.redeem(live[0]).github_id, '0', 'and never evicts a pending receipt');
194
196
  });
195
197
 
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
- });
198
+ test('a members-only project still lets the hub redeem a receipt — and nothing else', () => {
199
+ const { isExempt } = require_(path.join(ROOT, 'src/bongos/platform-visibility-gate.js'));
200
+ assert.equal(isExempt('/api/bongos/feedback/receipt/AbC_d-123456789012', 'GET'), true);
201
+ assert.equal(isExempt('/api/bongos/feedback/receipt', 'POST'), false, 'issuing one needs a member');
202
+ assert.equal(isExempt('/api/bongos/feedback/receipt/AbC/x', 'GET'), false);
203
+ assert.equal(isExempt('/api/bongos/feedback/receipt/AbC', 'DELETE'), false);
204
+ });
205
+
206
+ test('reports go to the Cloud Bongos hub unless overridden; the hub itself answers null', () => {
207
+ const { feedbackHub, DEFAULT_FEEDBACK_HUB } = require_(path.join(ROOT, 'src/bongos/routes/feedback.js'));
208
+ assert.equal(feedbackHub({ env: {}, isHub: false }), DEFAULT_FEEDBACK_HUB);
209
+ assert.equal(feedbackHub({ env: {}, isHub: true }), null);
210
+ assert.equal(feedbackHub({ env: { CLOUDBONGOS_FEEDBACK_HUB: 'https://test-hub.example.com/' }, isHub: false }), 'https://test-hub.example.com');
211
+ });
212
+
213
+ // ── the hub: the direct door (task 1004591) ──────────────────────────────────
214
+
215
+ function receiptFetch(receipt, status = 200) {
216
+ const calls = [];
217
+ const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { status, json: async () => receipt }; };
218
+ return { fetchImpl, calls };
219
+ }
220
+ const DEPS = (extra = {}) => ({
221
+ assertPublicHost: async () => {},
222
+ capture: async (c) => { DEPS.last = c; return { id: 3, routing: { routed: true, task_id: 77, goal_id: 9, status: 'backlog' } }; },
223
+ getAccount: async () => ({ github_login: 'hub-login' }),
224
+ hubBuilderId: async () => 99,
225
+ ...extra,
226
+ });
227
+
228
+ test('direct: a hosted project\'s matching receipt files the report as verified', async () => {
229
+ const { fetchImpl, calls } = receiptFetch({ github_id: '42', github_login: 'will', sha256: HASH, kind: 'bug', core_version: '1.21.70' });
230
+ const landing = await IF.fileDirectReport({
231
+ origin: 'https://mercury.cloudbongos.com', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug',
232
+ context: { cli_version: '1.21.70', os: 'darwin' }, hubHost: 'cloudbongos.com',
233
+ }, DEPS({ fetchImpl }));
234
+ assert.equal(calls[0].url, `https://mercury.cloudbongos.com/api/bongos/feedback/receipt/${'r'.repeat(24)}`);
235
+ assert.equal(calls[0].opts.redirect, 'error', 'a redirect is never followed');
236
+ assert.equal(landing.landed, 'task');
237
+ assert.equal(DEPS.last.filerRank, null, 'still waits for a nod');
238
+ assert.equal(DEPS.last.vector, 'instance-feedback');
239
+ assert.equal(DEPS.last.capturedBy, 99);
240
+ assert.match(DEPS.last.bodyMd, /Sent by: @hub-login/);
241
+ assert.match(DEPS.last.bodyMd, /verified \(a project Cloud Bongos hosts\)/);
242
+ assert.match(DEPS.last.bodyMd, /Core 1\.21\.70/);
243
+ });
244
+
245
+ test('direct: a self-hosted project\'s word is labelled and never borrows a hub account', async () => {
246
+ const { fetchImpl } = receiptFetch({ github_id: '42', github_login: 'will', sha256: HASH, kind: 'bug' });
247
+ const lookups = [];
248
+ await IF.fileDirectReport({
249
+ origin: 'https://bongos.acme.example', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug', hubHost: 'cloudbongos.com',
250
+ }, DEPS({
251
+ fetchImpl,
252
+ getAccount: async (id) => { lookups.push(['account', id]); return { github_login: 'hub-login' }; },
253
+ hubBuilderId: async (id) => { lookups.push(['builder', id]); return 99; },
254
+ }));
255
+ assert.deepEqual(lookups, [], 'neither hub row is even read for an unverified sender');
256
+ assert.equal(DEPS.last.capturedBy, null);
257
+ assert.doesNotMatch(DEPS.last.bodyMd, /hub-login/);
258
+ assert.match(DEPS.last.bodyMd, /Sent by: @will \(GitHub user 42\)/);
259
+ assert.match(DEPS.last.bodyMd, /confirmed by bongos\.acme\.example \(self-hosted, not verified by us\)/);
260
+ });
261
+
262
+ test('direct: a report the project does not vouch for is dropped, never filed', async () => {
263
+ const filed = [];
264
+ const deps = (receipt, status, extra) => DEPS({ ...receiptFetch(receipt, status), capture: async (c) => { filed.push(c); return {}; }, ...extra });
265
+ const base = { origin: 'https://p.example.com', receipt: 'r'.repeat(24), message: 'it broke', kind: 'bug', hubHost: 'cloudbongos.com' };
266
+ const good = { github_id: '42', sha256: HASH, kind: 'bug' };
267
+ assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, sha256: IF.sha256Hex('something else') })), { dropped: 'text_mismatch' });
268
+ assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, kind: 'idea' })), { dropped: 'kind_mismatch' });
269
+ assert.deepEqual(await IF.fileDirectReport(base, deps(good, 404)), { dropped: 'receipt_http_404' });
270
+ assert.deepEqual(await IF.fileDirectReport(base, deps({ ...good, github_id: 'x' })), { dropped: 'receipt_malformed' });
271
+ assert.deepEqual(await IF.fileDirectReport(base, deps(good, 200, { assertPublicHost: async () => { throw new Error('private'); } })), { dropped: 'private_host' });
272
+ assert.deepEqual(await IF.fileDirectReport({ ...base, receipt: '../../x' }, deps(good)), { dropped: 'bad_receipt' });
273
+ assert.equal(filed.length, 0);
274
+ });
275
+
276
+ test('direct: only a bare https origin on a public DNS name is ever called', () => {
277
+ for (const bad of ['http://p.example.com', 'https://127.0.0.1', 'https://[::1]', 'https://localhost', 'https://p.example.com:8443',
278
+ 'https://p.example.com/path', 'https://u:p@p.example.com', 'https://box.internal', 'https://intranet', 'ftp://p.example.com']) {
279
+ assert.equal(IF.projectOrigin(bad), null, bad);
280
+ }
281
+ assert.deepEqual(IF.projectOrigin('https://Mercury.CloudBongos.com/'), { origin: 'https://mercury.cloudbongos.com', host: 'mercury.cloudbongos.com' });
282
+ assert.equal(IF.isHostedBy('mercury.cloudbongos.com', 'cloudbongos.com'), true);
283
+ assert.equal(IF.isHostedBy('evilcloudbongos.com', 'cloudbongos.com'), false);
284
+ assert.equal(IF.isHostedBy('x.example.com', ''), false);
285
+ });
286
+
287
+ test('direct route: answers 202 before verifying, and caps BEFORE anything is fetched', () => {
288
+ const src = fs.readFileSync(path.join(ROOT, 'modules/platform-identity/routes/sso.js'), 'utf8');
289
+ const route = src.slice(src.indexOf("router.post('/sso/feedback/direct'"));
290
+ const at = (s) => { const i = route.indexOf(s); assert.ok(i > 0, `route has ${s}`); return i; };
291
+ assert.ok(at('projectOrigin') < at('directIpLimiter.take'));
292
+ assert.ok(at("directIpLimiter.take(ip, 'direct')") < at('directFeedbackLimiter.take'), 'a per-address cap no named host can dodge');
293
+ assert.ok(at('directFeedbackLimiter.take') < at('res.status(202)'));
294
+ assert.ok(at('res.status(202)') < at('fileDirectReport'));
295
+ assert.match(route, /assertPublicHost/);
209
296
  });
210
297
 
211
298
  // ── the CLI ───────────────────────────────────────────────────────────────────
212
299
 
213
- test('`bongos feedback` is a registered verb, and every refusal code has a plain line', () => {
300
+ test('`bongos feedback` is a registered verb, and every refusal has a plain line', () => {
214
301
  const { VERBS } = require_(path.join(ROOT, 'bin/bongos.js'));
215
302
  assert.equal(VERBS.feedback.script, 'feedback-send.js');
216
303
  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
- }
304
+ for (const code of ['message_required', 'too_long', 'local_only', 'no_scrubber']) assert.ok(REFUSALS[code], code);
223
305
  assert.equal(readMessage(['--idea', 'filter', 'by', 'goal']), 'filter by goal');
224
306
  });
225
307
 
308
+ // The skill tells the agent to use --message-file; it used to send the literal
309
+ // "[object Object]" because argText's result object was used as the text.
310
+ test('--message-file and stdin give the TEXT, never an object', () => {
311
+ const { readMessage } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
312
+ assert.equal(readMessage(['--message-file', '-'], { readStdin: () => 'from stdin' }), 'from stdin');
313
+ assert.equal(readMessage(['--message-file', 'x.md'], { readFile: () => 'from file' }), 'from file');
314
+ assert.equal(readMessage([], { stdinIsPiped: true, readStdin: () => 'piped' }), 'piped');
315
+ assert.equal(readMessage([], { stdinIsPiped: false }), '');
316
+ });
317
+
318
+ async function runCli(argv, extra = {}) {
319
+ const { main } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
320
+ const out = []; const err = [];
321
+ const log = console.log; const error = console.error;
322
+ console.log = (...a) => out.push(a.join(' ')); console.error = (...a) => err.push(a.join(' '));
323
+ const spawned = [];
324
+ try {
325
+ const code = await main(argv, {
326
+ requireSession: async () => ({ token: 't' }),
327
+ base: 'https://mercury.cloudbongos.com',
328
+ checkRepeat: () => false,
329
+ spawnDelivery: (job) => spawned.push(job),
330
+ ...extra,
331
+ });
332
+ return { code, out, err, spawned };
333
+ } finally { console.log = log; console.error = error; }
334
+ }
335
+
336
+ test('the CLI prints one line and hands the network to the background', async () => {
337
+ const r = await runCli(['claim', 'hung']);
338
+ assert.equal(r.code, 0);
339
+ assert.deepEqual(r.out, ['Sent to the Bongos team.']);
340
+ assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug' }]);
341
+ });
342
+
343
+ test('the CLI refuses a project only this computer can reach, and sends nothing', async () => {
344
+ for (const base of ['http://localhost:3000', 'http://127.0.0.1:3006', 'https://dev.test']) {
345
+ const r = await runCli(['x'], { base });
346
+ assert.equal(r.code, 1, base);
347
+ assert.equal(r.spawned.length, 0);
348
+ assert.match(r.err.join('\n'), /only on your computer/);
349
+ }
350
+ });
351
+
352
+ test('the same report twice inside the window is sent once', async () => {
353
+ const { checkAndRecordRepeat } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
354
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'fb-')), 'sent.json');
355
+ assert.equal(checkAndRecordRepeat('Claim hung\nfirst time', { file, now: 0 }), false);
356
+ assert.equal(checkAndRecordRepeat('claim hung \nsecond rant', { file, now: 1000 }), true);
357
+ assert.equal(checkAndRecordRepeat('a different problem', { file, now: 2000 }), false);
358
+ assert.equal(checkAndRecordRepeat('claim hung', { file, now: 7 * 60 * 60 * 1000 }), false, 'the window expires');
359
+ const r = await runCli(['x'], { checkRepeat: () => true });
360
+ assert.equal(r.spawned.length, 0);
361
+ assert.match(r.out[0], /Already sent/);
362
+ });
363
+
364
+ test('deliver: receipt for the exact text from the project, then the report straight to the hub', async () => {
365
+ const { deliver } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
366
+ const reqs = [];
367
+ const api = { request: async (m, p, o) => { reqs.push({ m, p, body: o.body }); return { ok: true, data: { receipt: 'R'.repeat(24), hub: 'https://hub.example.com' } }; } };
368
+ const sent = [];
369
+ const fetchImpl = async (url, opts) => { sent.push({ url, body: JSON.parse(opts.body) }); return { status: 202, json: async () => ({ ok: true }) }; };
370
+ const out = await deliver({ message: 'it broke', kind: 'bug' }, { api, base: 'https://mercury.cloudbongos.com', fetchImpl });
371
+ assert.deepEqual(out, { ok: true, hub: 'https://hub.example.com' });
372
+ assert.deepEqual(reqs[0], { m: 'POST', p: '/feedback/receipt', body: { sha256: IF.sha256Hex('it broke'), kind: 'bug' } });
373
+ assert.equal(sent[0].url, 'https://hub.example.com/api/bongos/sso/feedback/direct');
374
+ assert.equal(sent[0].body.origin, 'https://mercury.cloudbongos.com');
375
+ assert.equal(sent[0].body.receipt, 'R'.repeat(24));
376
+ assert.equal(sent[0].body.message, 'it broke');
377
+ const hubSelf = await deliver({ message: 'x', kind: 'bug' }, {
378
+ api: { request: async () => ({ ok: true, data: { receipt: 'R'.repeat(24), hub: null } }) },
379
+ base: 'https://cloudbongos.com', fetchImpl,
380
+ });
381
+ assert.equal(hubSelf.hub, 'https://cloudbongos.com', 'on the hub itself, the report goes to the project it talks to');
382
+ });
383
+
226
384
  // ── the hub route, driven for real ────────────────────────────────────────────
227
385
  // The assembled /sso router with its DB edges stubbed: the client lookup, the
228
386
  // membership read and the account read on the platform-identity domain, the
@@ -230,6 +388,11 @@ test('`bongos feedback` is a registered verb, and every refusal code has a plain
230
388
 
231
389
  async function withHub(overrides, fn) {
232
390
  const r2 = createRequire(import.meta.url);
391
+ // No real DNS from a unit test: the direct door's background redeem resolves
392
+ // the named host through project-stats, so refuse every host here.
393
+ const ps = r2(path.join(ROOT, 'modules/platform-identity/project-stats.js'));
394
+ const savedAssert = ps.assertPublicHost;
395
+ ps.assertPublicHost = async () => { throw new Error('no network in tests'); };
233
396
  const api = r2(path.join(ROOT, 'src/module-api.js'));
234
397
  const seams = r2(path.join(ROOT, 'src/module-seams.js'));
235
398
  const pi = r2(path.join(ROOT, 'modules/platform-identity/platform-identity.js'));
@@ -250,6 +413,7 @@ async function withHub(overrides, fn) {
250
413
  delete r2.cache[routerPath];
251
414
  const express = r2('express');
252
415
  const app = express();
416
+ app.set('trust proxy', 1); // as src/platform-server.js does
253
417
  app.use(express.json());
254
418
  app.use(r2(path.join(ROOT, 'src/bongos/middleware/error-envelope.js')).attachFail);
255
419
  app.use(r2(routerPath)());
@@ -258,10 +422,14 @@ async function withHub(overrides, fn) {
258
422
  const post = (body) => fetch(`http://127.0.0.1:${server.address().port}/sso/feedback`, {
259
423
  method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
260
424
  }).then(async (res) => ({ status: res.status, body: await res.json() }));
261
- try { await fn({ post, captured }); } finally {
425
+ const postTo = (p, body, headers = {}) => fetch(`http://127.0.0.1:${server.address().port}${p}`, {
426
+ method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
427
+ }).then(async (res) => ({ status: res.status, body: await res.json() }));
428
+ try { await fn({ post, postTo, captured }); } finally {
262
429
  server.close();
263
430
  Object.assign(pi, saved);
264
431
  api.pool.query = savedQuery;
432
+ ps.assertPublicHost = savedAssert;
265
433
  delete r2.cache[routerPath];
266
434
  }
267
435
  }
@@ -295,6 +463,36 @@ test('hub: a wrong secret, an unwitnessed sender and a forged field are all refu
295
463
  });
296
464
  });
297
465
 
466
+ // The router's limiters live for the life of the module, so each case below
467
+ // uses its own made-up caller addresses (sent as X-Forwarded-For; the harness
468
+ // trusts the proxy as the platform server does) and its own project hosts.
469
+ const direct = (postTo, ip, host) => postTo('/sso/feedback/direct',
470
+ { origin: `https://${host}`, receipt: 'r'.repeat(24), message: 'x' }, { 'X-Forwarded-For': ip });
471
+
472
+ test('direct route: one address naming a fresh host each time is still capped', async () => {
473
+ await withHub({}, async ({ postTo }) => {
474
+ const codes = [];
475
+ for (let i = 0; i < IF.PER_SENDER_LIMIT + 2; i++) codes.push((await direct(postTo, '203.0.113.1', `fresh${i}.example.com`)).status);
476
+ assert.deepEqual(codes.slice(0, IF.PER_SENDER_LIMIT), Array(IF.PER_SENDER_LIMIT).fill(202));
477
+ assert.equal(codes.at(-1), 429);
478
+ assert.equal((await postTo('/sso/feedback/direct', { origin: 'https://127.0.0.1', receipt: 'r'.repeat(24), message: 'x' })).status, 400);
479
+ });
480
+ });
481
+
482
+ test('direct route: callers never share a bucket, and a capped caller costs a project nothing', async () => {
483
+ await withHub({}, async ({ postTo }) => {
484
+ // Many different addresses, one report each: nobody is refused (no global bucket).
485
+ for (let i = 0; i < IF.PER_PROJECT_LIMIT + 5; i++) {
486
+ assert.equal((await direct(postTo, `198.51.100.${i + 1}`, `spread${i}.example.com`)).status, 202, `address ${i}`);
487
+ }
488
+ // An address past its own cap keeps naming a victim project: always 429...
489
+ for (let i = 0; i < IF.PER_SENDER_LIMIT; i++) await direct(postTo, '203.0.113.9', `burn${i}.example.com`);
490
+ for (let i = 0; i < IF.PER_PROJECT_LIMIT + 5; i++) assert.equal((await direct(postTo, '203.0.113.9', 'victim.example.com')).status, 429);
491
+ // ...and the victim's own builders still get through.
492
+ assert.equal((await direct(postTo, '192.0.2.50', 'victim.example.com')).status, 202);
493
+ });
494
+ });
495
+
298
496
  // ── proactive filing (task 1004467) ───────────────────────────────────────────
299
497
  // The owner's rule: a builder venting that Bongos is broken gets it filed without
300
498
  // asking. That lives in two places, and both must keep saying it.
@@ -334,6 +532,7 @@ test('every report is secret-scrubbed before it leaves the machine, and never se
334
532
  assert.ok(r.removed >= 2);
335
533
  assert.equal(scrubReport('bongos claim hung for two minutes', {}).text, 'bongos claim hung for two minutes', 'plain prose is untouched');
336
534
  const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/feedback-send.js'), 'utf8');
337
- assert.ok(src.indexOf('scrubReport(message)') < src.indexOf("api.request('POST', '/feedback'"), 'scrubbed before the request');
338
- assert.match(src, /message: scrubbed\.text/, 'only the scrubbed text is sent');
535
+ const main = src.slice(src.indexOf('async function main('));
536
+ assert.ok(main.indexOf('scrubReport(message)') < main.indexOf('spawnDelivery'), 'scrubbed before anything is handed off');
537
+ assert.match(main, /message: scrubbed\.text/, 'only the scrubbed text is sent');
339
538
  });