@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
@@ -1,40 +1,58 @@
1
1
  #!/usr/bin/env node
2
- // Send feedback about Bongos itself to the Cloud Bongos maintainers (task 1004462).
2
+ // Send feedback about Bongos itself to the Cloud Bongos maintainers (tasks 1004462, 1004591).
3
3
  //
4
4
  // Usage:
5
5
  // bongos feedback "the claim command hung for two minutes"
6
6
  // bongos feedback --idea "let me filter the task list by goal"
7
- // bongos feedback --message-file notes.md
8
- // [--json]
7
+ // bongos feedback --message-file notes.md (or --message-file - for stdin)
8
+ // [--wait] send in the foreground and print the outcome (debugging)
9
9
  //
10
- // Not the /feedback skill, which loads a locally recorded walkthrough into a
11
- // session. This SENDS: it posts to the builder's OWN project (POST /feedback,
12
- // src/bongos/routes/feedback.js), which relays it to the hub. A bug lands on the
13
- // hub as a 'backlog' task in the maintenance goal awaiting a maintainer's nod; an
14
- // idea lands in the hub's idea inbox. Only the typed text and version stamps
15
- // travel — never code, a transcript or file contents.
10
+ // Straight to the hub, never through the project: a project with no hub link can
11
+ // still send. The builder's OWN project vouches for who sent it — the CLI asks it
12
+ // for a one-time receipt (src/bongos/routes/feedback.js) and the hub redeems that
13
+ // receipt before filing (modules/platform-identity/instance-feedback.js). So a
14
+ // signed-in builder does nothing extra.
15
+ //
16
+ // Fast and quiet by design (the owner, 2026-10-06): the command prints one line
17
+ // and returns at once; a detached background process does the network. No task
18
+ // number comes back. Only the typed text (secret-scrubbed) and version stamps travel.
16
19
 
17
20
  'use strict';
18
21
 
22
+ const fs = require('node:fs');
19
23
  const os = require('node:os');
20
- const { cliClient, requireSession, argText, hasFlag, errCode, errDetails, cliExit } = require('./cli-lib');
24
+ const path = require('node:path');
25
+ const crypto = require('node:crypto');
26
+ const { spawn } = require('node:child_process');
27
+ const { cliClient, requireSession, loadSessionSync, argText, hasFlag, errCode, cliExit } = require('./cli-lib');
28
+ const ic = require('../../src/instance-config');
21
29
 
22
30
  const USAGE = [
23
31
  'usage:',
24
32
  ' bongos feedback "what went wrong" report a problem with Bongos',
25
33
  ' bongos feedback --idea "what you wish for" suggest an improvement',
26
- ' bongos feedback --message-file notes.md send a longer note from a file',
34
+ ' bongos feedback --message-file notes.md send a longer note from a file (- for stdin)',
27
35
  '',
28
36
  'Goes to the Cloud Bongos maintainers. Only the text you type and the Bongos',
29
37
  'versions you run are sent — no code, chat or files from your project.',
30
38
  ].join('\n');
31
39
 
32
- // The CLI release, as `bongos version` reports it: config/bongos-cli.json where
33
- // the core checkout carries it (an instance does not — task 1824), else the
34
- // package version. Read from disk, never required, so a vanilla core still runs.
40
+ const SENT_LINE = 'Sent to the Bongos team.';
41
+ const REPEAT_WINDOW_MS = 6 * 60 * 60 * 1000;
42
+
43
+ // Plain lines for the refusals the builder can see (everything else happens in
44
+ // the background and is written to the outcome file, never to the terminal).
45
+ const REFUSALS = {
46
+ message_required: 'Nothing to send — add your feedback after the command, in quotes.',
47
+ too_long: 'That is too long to send — keep it under 4,000 characters, or split it into two.',
48
+ local_only: 'This project runs only on your computer, so Cloud Bongos cannot confirm who sent the report. Nothing was sent.',
49
+ no_scrubber: 'Feedback was not sent: the secret filter that checks every report is missing from this install.',
50
+ };
51
+
52
+ function stateDir() { return path.join(os.homedir(), '.config', ic.FALLBACK_DIR || 'cloudbongos'); }
53
+
54
+ // The CLI release, as `bongos version` reports it. Read from disk, never required.
35
55
  function cliVersion() {
36
- const fs = require('node:fs');
37
- const path = require('node:path');
38
56
  for (const rel of ['../../config/bongos-cli.json', '../../package.json']) {
39
57
  try {
40
58
  const f = path.join(__dirname, rel);
@@ -44,17 +62,19 @@ function cliVersion() {
44
62
  return null;
45
63
  }
46
64
 
47
- // The message: --message / --message-file, else every positional word joined.
48
- function readMessage(args) {
49
- if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args);
50
- return args.filter((a) => !a.startsWith('--')).join(' ');
65
+ // The message: --message / --message-file (incl. - for stdin), else every
66
+ // positional word joined, else whatever was piped in.
67
+ function readMessage(args, io = {}) {
68
+ if (args.includes('--message') || args.includes('--message-file')) return argText('--message', args, io).value || '';
69
+ const words = args.filter((a) => !a.startsWith('--')).join(' ');
70
+ if (words) return words;
71
+ const stdinIsPiped = io.stdinIsPiped !== undefined ? io.stdinIsPiped : !process.stdin.isTTY;
72
+ if (!stdinIsPiped) return '';
73
+ try { return (io.readStdin || (() => fs.readFileSync(0, 'utf8')))(); } catch { return ''; }
51
74
  }
52
75
 
53
76
  // Every report goes through the transcript-grade secret scrubber before it leaves
54
- // this machine (task 1004467). An assistant may file unasked when a builder vents,
55
- // so nobody reviews the text first — the scrubber is the check that does not
56
- // depend on the assistant's judgment. FAIL CLOSED: no scrubber, nothing is sent.
57
- // It also strips the builder's environment secrets by value, whatever their shape.
77
+ // this machine (task 1004467). FAIL CLOSED: no scrubber, nothing is sent.
58
78
  function scrubReport(message, env = process.env) {
59
79
  let scrub;
60
80
  try { scrub = require('../../modules/security/secret-scrub.js'); } catch { return null; }
@@ -63,69 +83,122 @@ function scrubReport(message, env = process.env) {
63
83
  return { text, removed };
64
84
  }
65
85
 
66
- // Plain-language lines for each refusal the route can give.
67
- const REFUSALS = {
68
- message_required: 'Nothing to send — add your feedback after the command, in quotes.',
69
- validation_failed: 'That could not be sent as written — keep it under 4,000 characters, or split it into two.',
70
- not_hub_connected: 'This project is not connected to Cloud Bongos, so feedback cannot be relayed from here.\n'
71
- + 'Report it to the Bongos maintainers on the Bongos core repository instead.',
72
- membership_required: 'Cloud Bongos does not yet know you as a member of this project.\n'
73
- + 'Sign in to this project once through Cloud Bongos in a browser, then try again.',
74
- rate_limited: 'Too much feedback from you or this project in the last hour. Try again later.',
75
- feedback_unavailable: 'Cloud Bongos is not taking bug reports right now (no version is being built). Try again later.',
76
- hub_unreachable: 'Could not reach Cloud Bongos. Check your connection and try again.',
77
- };
86
+ // A project the hub cannot reach can never vouch for a report — say so up front
87
+ // rather than send something that is certain to be dropped.
88
+ function isLocalBase(base) {
89
+ let host;
90
+ try { host = new URL(base).hostname.toLowerCase(); } catch { return true; }
91
+ if (require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return true;
92
+ return !host.includes('.') || /(^|\.)(localhost|local|internal|test|invalid)$/.test(host);
93
+ }
94
+
95
+ // Repeat guard: the same report (same first line) inside REPEAT_WINDOW_MS is not
96
+ // sent twice, so an assistant filing on every rant cannot spam the maintainers.
97
+ function repeatKey(message) {
98
+ const first = String(message).split('\n').map((l) => l.trim().toLowerCase()).find(Boolean) || '';
99
+ return crypto.createHash('sha256').update(first).digest('hex').slice(0, 16);
100
+ }
101
+ function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-sent.json'), now = Date.now() } = {}) {
102
+ let seen = {};
103
+ try { seen = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first report */ }
104
+ for (const [k, t] of Object.entries(seen)) if (!(now - t < REPEAT_WINDOW_MS)) delete seen[k];
105
+ const key = repeatKey(message);
106
+ if (Object.prototype.hasOwnProperty.call(seen, key)) return true;
107
+ seen[key] = now;
108
+ try {
109
+ fs.mkdirSync(path.dirname(file), { recursive: true });
110
+ fs.writeFileSync(file, JSON.stringify(seen), { mode: 0o600 });
111
+ } catch { /* a guard that cannot write still lets the report through */ }
112
+ return false;
113
+ }
114
+
115
+ // deliver — the network half: receipt from the project, then the report to the
116
+ // hub. Returns an outcome object; never prints. Runs in the background child.
117
+ async function deliver({ message, kind }, deps = {}) {
118
+ const api = deps.api || await cliClient();
119
+ const base = deps.base || ic.resolveApiBase({ session: loadSessionSync() });
120
+ const doFetch = deps.fetchImpl || fetch;
121
+ const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
122
+ const r = await api.request('POST', '/feedback/receipt', { body: { sha256, kind } });
123
+ if (!r.ok) return { ok: false, step: 'receipt', code: errCode(r.data) || `http_${r.status}` };
124
+ const origin = new URL(base).origin;
125
+ const hub = String((r.data && r.data.hub) || origin).replace(/\/+$/, '');
126
+ const res = await doFetch(`${hub}/api/bongos/sso/feedback/direct`, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
129
+ body: JSON.stringify({
130
+ origin, receipt: r.data.receipt, message, kind,
131
+ context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` },
132
+ }),
133
+ signal: AbortSignal.timeout(15000),
134
+ });
135
+ if (res.status === 202) return { ok: true, hub };
136
+ let body = null;
137
+ try { body = await res.json(); } catch { /* non-JSON */ }
138
+ return { ok: false, step: 'hub', code: errCode(body) || `http_${res.status}` };
139
+ }
140
+
141
+ // The background child: read the queued report, deliver it, record the outcome
142
+ // where a curious builder (or a maintainer helping them) can look.
143
+ async function runDeliverChild(file) {
144
+ let job;
145
+ try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); }
146
+ let outcome;
147
+ try { outcome = await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
148
+ try {
149
+ fs.writeFileSync(path.join(stateDir(), 'feedback-last.json'), JSON.stringify({ at: new Date().toISOString(), ...outcome }), { mode: 0o600 });
150
+ } catch { /* nowhere to record — the report itself is what mattered */ }
151
+ return outcome.ok ? 0 : 1;
152
+ }
153
+
154
+ // Hand the job to a detached child and return. The job travels in a 0600 temp
155
+ // file, never argv (visible in ps) or env.
156
+ function spawnDelivery(job, { spawnImpl = spawn } = {}) {
157
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-feedback-'));
158
+ const file = path.join(dir, 'job.json');
159
+ fs.writeFileSync(file, JSON.stringify(job), { mode: 0o600 });
160
+ const child = spawnImpl(process.execPath, [__filename, '--deliver', file], { detached: true, stdio: 'ignore' });
161
+ if (child && child.unref) child.unref();
162
+ return file;
163
+ }
78
164
 
79
- async function main(argv = process.argv.slice(2)) {
165
+ async function main(argv = process.argv.slice(2), deps = {}) {
80
166
  if (hasFlag('--help', argv) || hasFlag('-h', argv)) { console.log(USAGE); return 0; }
81
- const json = hasFlag('--json', argv);
82
167
  const kind = hasFlag('--idea', argv) ? 'idea' : 'bug';
83
- const message = String(readMessage(argv) || '').trim();
84
- if (!message) { console.error(USAGE); return 2; }
168
+ const message = String(readMessage(argv, deps.io) || '').trim();
169
+ if (!message) { console.error(REFUSALS.message_required); return 2; }
170
+ if (message.length > 4000) { console.error(REFUSALS.too_long); return 2; }
85
171
 
86
172
  const scrubbed = scrubReport(message);
87
- if (!scrubbed) {
88
- console.error('Feedback was not sent: the secret filter that checks every report is missing from this install.');
89
- return 1;
90
- }
173
+ if (!scrubbed) { console.error(REFUSALS.no_scrubber); return 1; }
91
174
 
92
- await requireSession();
93
- const api = await cliClient();
94
- const res = await api.request('POST', '/feedback', {
95
- body: { message: scrubbed.text, kind, context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` } },
96
- });
97
- if (scrubbed.removed > 0 && !json) {
98
- console.log(`Removed ${scrubbed.removed} secret-looking value(s) from the report before sending.`);
99
- }
175
+ const session = await (deps.requireSession || requireSession)();
176
+ const base = deps.base || ic.resolveApiBase({ session });
177
+ if (isLocalBase(base)) { console.error(REFUSALS.local_only); return 1; }
100
178
 
101
- if (json) { console.log(JSON.stringify(res.data, null, 2)); return res.ok ? 0 : 1; }
102
- if (!res.ok) {
103
- const code = errCode(res.data);
104
- const details = errDetails(res.data) || {};
105
- let line = REFUSALS[code] || `Feedback was not sent (${code || `HTTP ${res.status}`}).`;
106
- if (code === 'rate_limited' && details.retry_after_seconds) {
107
- line += ` (in about ${Math.ceil(details.retry_after_seconds / 60)} min)`;
108
- }
109
- console.error(line);
110
- return 1;
179
+ const job = { message: scrubbed.text, kind };
180
+ if ((deps.checkRepeat || checkAndRecordRepeat)(job.message)) {
181
+ console.log('Already sent — the Bongos team has this one.');
182
+ return 0;
111
183
  }
112
- const d = res.data || {};
113
- if (d.landed === 'task') {
114
- console.log(`Thanks — sent to the Cloud Bongos maintainers as task ${d.task_id}.`);
115
- console.log('It waits for a maintainer to approve it before anyone works on it.');
116
- if (d.hub_origin) console.log(`${String(d.hub_origin).replace(/\/+$/, '')}/builders#/task/${d.task_id}`);
117
- } else {
118
- console.log(`Thanks — your idea is in the Cloud Bongos idea inbox (idea ${d.idea_id}) for the maintainers to review.`);
184
+ if (hasFlag('--wait', argv)) {
185
+ const outcome = await (deps.deliver || deliver)(job);
186
+ console.log(outcome.ok ? SENT_LINE : `Not sent (${outcome.step}: ${outcome.code}).`);
187
+ return outcome.ok ? 0 : 1;
119
188
  }
189
+ (deps.spawnDelivery || spawnDelivery)(job);
190
+ console.log(SENT_LINE);
120
191
  return 0;
121
192
  }
122
193
 
123
194
  if (require.main === module) {
195
+ const argv = process.argv.slice(2);
196
+ const run = argv[0] === '--deliver' ? runDeliverChild(argv[1]) : main(argv);
124
197
  // cliExit, never process.exit, after a request (task 1002734 — the Windows abort).
125
- main().then((code) => cliExit(code), (err) => {
198
+ run.then((code) => cliExit(code), (err) => {
126
199
  console.error(`Feedback was not sent: ${err && err.message ? err.message : err}`);
127
200
  cliExit(1);
128
201
  });
129
202
  }
130
203
 
131
- module.exports = { main, readMessage, REFUSALS, scrubReport };
204
+ module.exports = { main, deliver, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
@@ -110,6 +110,9 @@ const EXEMPT = [
110
110
  /^\/downloads\//,
111
111
  { re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
112
112
  { re: new RegExp(`^${API}/access-requests/status$`), methods: ['GET'] },
113
+ // The hub redeeming a `bongos feedback` receipt (task 1004591): it has no
114
+ // session here, and a members-only project must still vouch for its builders.
115
+ { re: new RegExp(`^${API}/feedback/receipt/[a-z0-9_-]+$`), methods: ['GET'] },
113
116
  ];
114
117
 
115
118
  // The policy, read through the memoized pack. A pack that cannot be read at all
@@ -1,110 +1,124 @@
1
1
  'use strict';
2
2
 
3
- // src/bongos/routes/feedback.js — POST /api/bongos/feedback (task 1004462).
3
+ // src/bongos/routes/feedback.js — the project's half of `bongos feedback`: a
4
+ // one-time RECEIPT that proves who sent a report (tasks 1004462, 1004591).
4
5
  //
5
- // `bongos feedback "…"` lands here, on the builder's OWN project, with the session
6
- // they already hold. This route stamps who sent it and which core this project
7
- // runs, then relays it to the hub's POST /sso/feedback over the hub-federation
8
- // client credential (ADR 0141) — the channel the activity rollup already uses. So
9
- // a builder never signs in to cloudbongos.com to report something about Bongos.
6
+ // The report itself goes straight from the builder's CLI to the Cloud Bongos hub,
7
+ // never through this project — so a project with no hub link (self-hosted, or a
8
+ // hosted one provisioned before federation) can still send. What the hub cannot
9
+ // know on its own is WHO sent it. This project can: the builder holds a session
10
+ // here. So the CLI first asks for a receipt naming the signed-in builder and a
11
+ // hash of the text; the hub then calls this project's public receipt URL and
12
+ // files the report only if the project vouches for both. Nothing for the builder
13
+ // to do beyond the session they already have.
10
14
  //
11
- // Only the text the builder typed and version stamps travel. No code, transcript,
12
- // task or file content from this project is attached.
15
+ // Two routes:
16
+ // POST /feedback/receipt — signed-in builder; records { who, sha256, kind }.
17
+ // GET /feedback/receipt/:id — public, single-use; the hub redeems it once.
13
18
  //
14
- // In the CORE, not a module, for the reason core-update.js gives: the hub link is
15
- // kernel (src/bongos/auth-config.js), and every hosted project must have this door
16
- // whatever modules it runs. A project with no hub link (self-hosted, or the hub
17
- // itself) answers 409 not_hub_connected — told, not silently dropped.
19
+ // Receipts live in memory for RECEIPT_TTL_MS: the CLI redeems within seconds, and
20
+ // a restart in between only loses that one report. In the CORE, not a module, for
21
+ // the reason core-update.js gives: every project must have this door.
18
22
 
23
+ const crypto = require('node:crypto');
19
24
  const express = require('express');
20
25
  const { asyncHandler, validateOrRespond } = require('./_helpers');
21
26
  const auth = require('../auth');
22
- const { loadIdpConfig } = require('../auth-config');
23
- const { userAgent } = require('../../branding');
27
+ const ic = require('../../instance-config');
24
28
 
25
- const MESSAGE_MAX = 4000;
26
- const HUB_TIMEOUT_MS = 15000;
29
+ const RECEIPT_TTL_MS = 10 * 60 * 1000;
30
+ const RECEIPTS_MAX = 1000;
27
31
 
28
- function safeIdpConfig() {
29
- try { return loadIdpConfig(); } catch { return null; }
32
+ // Where reports go: the Bongos maintainers' hub. <PREFIX>_FEEDBACK_HUB overrides
33
+ // it (a test hub, a fork with its own maintainers). A project that IS the hub
34
+ // answers null — the CLI then sends to the project it already talks to.
35
+ const DEFAULT_FEEDBACK_HUB = 'https://cloudbongos.com';
36
+
37
+ function feedbackHub({ env, isHub } = {}) {
38
+ const override = ic.resolveEnv('FEEDBACK_HUB', env ? { env } : undefined);
39
+ if (override) return String(override).replace(/\/+$/, '');
40
+ let hub = isHub;
41
+ if (hub === undefined) {
42
+ try { hub = require('../../modules').isModuleEnabled('platform-identity'); } catch { hub = false; }
43
+ }
44
+ return hub ? null : DEFAULT_FEEDBACK_HUB;
30
45
  }
31
46
 
32
47
  function runningCore() {
33
48
  try { return require('../../module-api').CORE_VERSION || null; } catch { return null; }
34
49
  }
35
50
 
36
- // Hub refusals the builder can act on are passed through by code; anything else
37
- // is one generic failure, so the hub's internals never leak through this door.
38
- const PASS_THROUGH = new Set(['membership_required', 'rate_limited', 'feedback_unavailable', 'validation_failed']);
39
-
40
- async function relayToHub({ idp, builder, message, kind, context, fetchImpl = fetch }) {
41
- const res = await fetchImpl(`${idp.origin.replace(/\/+$/, '')}/api/bongos/sso/feedback`, {
42
- method: 'POST',
43
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
44
- body: JSON.stringify({
45
- client_id: idp.clientId,
46
- client_secret: idp.clientSecret,
47
- github_id: builder.github_id,
48
- message,
49
- kind,
50
- context,
51
- }),
52
- signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
53
- });
54
- let body = null;
55
- try { body = await res.json(); } catch { /* non-JSON answer */ }
56
- return { status: res.status, body };
51
+ // makeReceiptStore — issue/redeem with expiry, single use, and a size cap so an
52
+ // abusive signed-in caller cannot grow the map without bound. A full store
53
+ // refuses the NEW receipt (issue → null) rather than evicting a live one, so one
54
+ // noisy builder can never drop another builder's pending report.
55
+ function makeReceiptStore({ now = Date.now, ttlMs = RECEIPT_TTL_MS, max = RECEIPTS_MAX } = {}) {
56
+ const rows = new Map(); // id -> { ...receipt, exp }
57
+ function prune() {
58
+ const t = now();
59
+ for (const [id, r] of rows) if (r.exp <= t) rows.delete(id);
60
+ }
61
+ return {
62
+ issue(receipt) {
63
+ prune();
64
+ if (rows.size >= max) return null;
65
+ const id = crypto.randomBytes(18).toString('base64url');
66
+ rows.set(id, { ...receipt, exp: now() + ttlMs });
67
+ return id;
68
+ },
69
+ redeem(id) {
70
+ const r = rows.get(id);
71
+ rows.delete(id);
72
+ if (!r || r.exp <= now()) return null;
73
+ const { exp: _exp, ...receipt } = r;
74
+ return receipt;
75
+ },
76
+ _rows: rows,
77
+ };
57
78
  }
58
79
 
59
- function buildFeedbackRouter({ idpConfig = safeIdpConfig, fetchImpl = fetch } = {}) {
80
+ function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } = {}) {
60
81
  const router = express.Router();
61
82
 
62
- // rank: any signed-in builder — reporting a problem with Bongos grants nothing
63
- // here, and the hub files every report at 'backlog' behind a Metic+ nod.
83
+ // rank: any signed-in builder — a receipt only says "this builder sent this
84
+ // text"; it grants nothing here, and the hub files every report behind a nod.
64
85
  router.post(
65
- '/feedback',
86
+ '/feedback/receipt',
66
87
  auth.requireBuilder,
67
- asyncHandler('POST /feedback', async (req, res) => {
88
+ asyncHandler('POST /feedback/receipt', async (req, res) => {
68
89
  if (validateOrRespond(req, res, {
69
- message: { required: true, type: 'string', maxLength: MESSAGE_MAX },
90
+ sha256: { required: true, type: 'string', maxLength: 64 },
70
91
  kind: { type: 'string', enum: ['bug', 'idea'] },
71
- context: {},
72
92
  })) return;
73
- const b = req.body || {};
74
- const message = b.message.trim();
75
- if (!message) return res.fail('message_required', 400);
76
- const kind = b.kind || 'bug';
77
-
78
- const idp = idpConfig();
79
- if (!idp) return res.fail('not_hub_connected', 409);
80
-
81
- const ctx = b.context && typeof b.context === 'object' ? b.context : {};
82
- const context = {
93
+ const b = req.body;
94
+ if (!/^[a-f0-9]{64}$/.test(b.sha256)) return res.fail('validation_failed', 400, [{ field: 'sha256', reason: 'not_a_sha256' }]);
95
+ const receipt = store.issue({
96
+ github_id: String(req.builder.github_id),
97
+ github_login: req.builder.github_login || null,
98
+ sha256: b.sha256,
99
+ kind: b.kind || 'bug',
83
100
  core_version: runningCore(),
84
- cli_version: typeof ctx.cli_version === 'string' ? ctx.cli_version.slice(0, 40) : null,
85
- os: typeof ctx.os === 'string' ? ctx.os.slice(0, 40) : null,
86
- };
87
-
88
- let hub;
89
- try {
90
- hub = await relayToHub({ idp, builder: req.builder, message, kind, context, fetchImpl });
91
- } catch {
92
- return res.fail('hub_unreachable', 502);
93
- }
94
- if (hub.status === 201 && hub.body && hub.body.ok) {
95
- const { ok: _ok, ...landing } = hub.body;
96
- return res.status(201).json({ ok: true, hub_origin: idp.origin, ...landing });
97
- }
98
- const err = hub.body && hub.body.error && typeof hub.body.error === 'object' ? hub.body.error : null;
99
- if (err && PASS_THROUGH.has(err.code)) {
100
- const retry = err.details && err.details.retry_after_seconds;
101
- return res.fail(err.code, hub.status, retry ? { retry_after_seconds: retry } : undefined);
102
- }
103
- return res.fail('hub_refused', 502, { hub_status: hub.status });
104
- }, { errorCode: 'feedback_failed', message: false }),
101
+ });
102
+ if (!receipt) return res.fail('rate_limited', 429, { retry_after_seconds: Math.ceil(RECEIPT_TTL_MS / 1000) });
103
+ res.status(201).json({ ok: true, receipt, hub: hub() });
104
+ }, { errorCode: 'feedback_receipt_failed', message: false }),
105
105
  );
106
106
 
107
+ // rank: public — the hub redeems a receipt with no session of its own. The id
108
+ // is 144 random bits, known only to the CLI that asked for it and the hub it
109
+ // sent it to, and it works once.
110
+ router.get('/feedback/receipt/:id', (req, res) => {
111
+ const id = String(req.params.id || '');
112
+ const receipt = /^[A-Za-z0-9_-]{16,64}$/.test(id) ? store.redeem(id) : null;
113
+ res.set('Cache-Control', 'no-store');
114
+ if (!receipt) return res.fail('receipt_unknown', 404);
115
+ res.json({ ok: true, ...receipt });
116
+ });
117
+
107
118
  return router;
108
119
  }
109
120
 
110
121
  module.exports = buildFeedbackRouter;
122
+ module.exports.makeReceiptStore = makeReceiptStore;
123
+ module.exports.feedbackHub = feedbackHub;
124
+ module.exports.DEFAULT_FEEDBACK_HUB = DEFAULT_FEEDBACK_HUB;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.68'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.70'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -246,7 +246,6 @@ test('loadMemberships reads GET /my-projects\' applied echo into appliedOrigins,
246
246
  API: '/api/bongos',
247
247
  mineOrigins: {},
248
248
  appliedOrigins: {},
249
- renderBuildingList: () => {},
250
249
  hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
251
250
  fetch: () => Promise.resolve({
252
251
  ok: true,