@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.
- package/.bongos-core.json +36 -36
- package/.claude/skills/bongos-feedback/SKILL.md +10 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -2
- package/clients/bongos-client/index.cjs +6 -2
- package/clients/bongos-client/index.d.ts +10 -3
- package/clients/bongos-client/index.mjs +6 -2
- package/docs/api/openapi.json +151 -15
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +371 -366
- package/docs/copy-registry.json +457 -412
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +718 -714
- package/modules/platform-identity/instance-feedback.js +108 -7
- package/modules/platform-identity/routes/sso.js +71 -0
- package/modules/public-landing/public/account.html +88 -1
- package/modules/public-landing/public/account.probes.json +9 -0
- package/modules/public-landing/public/account.states.json +2 -1
- package/modules/public-landing/public/assets/cosmos.css +3 -0
- package/modules/public-landing/public/projects.html +14 -81
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +11 -0
- package/scripts/gds/feedback-send.js +144 -71
- package/src/bongos/platform-visibility-gate.js +3 -0
- package/src/bongos/routes/feedback.js +91 -77
- package/src/module-api.js +1 -1
- package/tests/card_apply_ui.mjs +0 -1
- package/tests/hub_account_page.mjs +132 -6
- package/tests/instance_feedback.mjs +267 -68
- 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 (
|
|
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
|
-
// [--
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// src/bongos/routes/feedback.js)
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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).
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
console.log(
|
|
115
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
23
|
-
const { userAgent } = require('../../branding');
|
|
27
|
+
const ic = require('../../instance-config');
|
|
24
28
|
|
|
25
|
-
const
|
|
26
|
-
const
|
|
29
|
+
const RECEIPT_TTL_MS = 10 * 60 * 1000;
|
|
30
|
+
const RECEIPTS_MAX = 1000;
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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({
|
|
80
|
+
function buildFeedbackRouter({ store = makeReceiptStore(), hub = feedbackHub } = {}) {
|
|
60
81
|
const router = express.Router();
|
|
61
82
|
|
|
62
|
-
// rank: any signed-in builder —
|
|
63
|
-
// here, and the hub files every report
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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.
|
|
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');
|
package/tests/card_apply_ui.mjs
CHANGED
|
@@ -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,
|