@bongos/core 1.21.69 → 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 +24 -24
- 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/module-api-changelog.md +2 -0
- package/modules/platform-identity/instance-feedback.js +108 -7
- package/modules/platform-identity/routes/sso.js +71 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +5 -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/instance_feedback.mjs +267 -68
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 522 endpoints across 93 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -261,11 +261,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
261
261
|
| POST | `/api/bongos/done-when/:criterionId/unsatisfy` | `metic+archon` | `reason` | Auth: flip a criterion back to unsatisfied (rare; for corrections). |
|
|
262
262
|
| GET | `/api/bongos/done-when/pending-review` | `metic+archon` | — | Auth (Metic+): the criterion review queue — every criterion auto-flagged "met — pending review" (≥1 linked task, all shipped, not yet con… |
|
|
263
263
|
|
|
264
|
-
## `feedback` (
|
|
264
|
+
## `feedback` (2)
|
|
265
265
|
|
|
266
266
|
| Method | Path | Rank | Body | Description |
|
|
267
267
|
|---|---|---|---|---|
|
|
268
|
-
| POST | `/api/bongos/feedback` | `any-builder` | `
|
|
268
|
+
| POST | `/api/bongos/feedback/receipt` | `any-builder` | `sha256`, `kind` | signed-in builder — a receipt only says "this builder sent this text"; it grants nothing here, and the hub files every report behind a nod. |
|
|
269
|
+
| GET | `/api/bongos/feedback/receipt/:id` | `public` | — | the hub redeems a receipt with no session of its own. |
|
|
269
270
|
|
|
270
271
|
## `founding` (6)
|
|
271
272
|
|
|
@@ -793,7 +794,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
793
794
|
| GET | `/api/bongos/specialities/installed-skills` | `any-builder` | — | GET /specialities/installed-skills — every skill THIS instance has, each explained the way the adoption walkthrough explains it, so a spe… |
|
|
794
795
|
| GET | `/api/bongos/specialities/offered` | `any-builder` | — | GET /specialities/offered — the suggestions for THIS builder. |
|
|
795
796
|
|
|
796
|
-
## `sso` (
|
|
797
|
+
## `sso` (12)
|
|
797
798
|
|
|
798
799
|
| Method | Path | Rank | Body | Description |
|
|
799
800
|
|---|---|---|---|---|
|
|
@@ -803,6 +804,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
|
|
|
803
804
|
| POST | `/api/bongos/sso/device/poll` | `public` | `device_code` | POST /sso/device/poll — poll the hub device flow. |
|
|
804
805
|
| POST | `/api/bongos/sso/device/start` | `public` | `origin` | POST /sso/device/start — begin CLI federation. |
|
|
805
806
|
| POST | `/api/bongos/sso/feedback` | `public` | `client_id`, `client_secret`, `github_id`, `message`, `kind`, `context` | POST /sso/feedback — a federated project relays one builder's feedback about Bongos itself (task 1004462). |
|
|
807
|
+
| POST | `/api/bongos/sso/feedback/direct` | `public` | `origin`, `receipt`, `message`, `kind`, `context` | POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI, with a one-time receipt from their own project (task 1004591). |
|
|
806
808
|
| POST | `/api/bongos/sso/invites/clear` | `public` | `client_id`, `client_secret`, `github_login` | authenticated by client_id + client_secret, not a builder rank. |
|
|
807
809
|
| POST | `/api/bongos/sso/invites/notify` | `public` | `client_id`, `client_secret`, `github_login` | authenticated by client_id + client_secret, not a builder rank. |
|
|
808
810
|
| POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `membership_kind` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
|
|
@@ -2970,5 +2970,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2970
2970
|
landed since 1.21.67 with no explicit bump. run 37511231885. (task 1002620)
|
|
2971
2971
|
1.21.69 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2972
2972
|
landed since 1.21.68 with no explicit bump. run 37516251453. (task 1002620)
|
|
2973
|
+
1.21.70 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2974
|
+
landed since 1.21.69 with no explicit bump. run 37524456209. (task 1002620)
|
|
2973
2975
|
---------------------------------------------------------------------------
|
|
2974
2976
|
```
|
|
@@ -21,6 +21,15 @@
|
|
|
21
21
|
// an absent rank as untrusted, so even the owner's own feedback waits for the nod
|
|
22
22
|
// rather than inheriting a hub rank the instance never vouched for.
|
|
23
23
|
//
|
|
24
|
+
// TWO DOORS IN (task 1004591). The relay above needs the project linked to the hub,
|
|
25
|
+
// which many are not, so the CLI now sends STRAIGHT here (POST /sso/feedback/direct)
|
|
26
|
+
// with a one-time receipt from its own project (src/bongos/routes/feedback.js).
|
|
27
|
+
// The hub answers at once, then redeems the receipt at the project's public URL:
|
|
28
|
+
// the project names the signed-in sender and the hash of the text, and only a
|
|
29
|
+
// matching report is filed. A project on the hub's own domain is one we run, so
|
|
30
|
+
// its word is verified; any other domain is printed as "confirmed by <host>".
|
|
31
|
+
// The relay stays for projects on cores older than the receipt.
|
|
32
|
+
//
|
|
24
33
|
// THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
|
|
25
34
|
// PROJECT is calling and nothing about WHO sent it, so the sender must hold a
|
|
26
35
|
// membership on that project carrying the hub's own sign-in witness
|
|
@@ -86,8 +95,11 @@ function feedbackTitle(message) {
|
|
|
86
95
|
return `Feedback: ${title}`;
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
90
|
-
|
|
98
|
+
function feedbackBody({ message, client, account, githubId, context = {}, vouch = null }) {
|
|
99
|
+
// vouch (direct reports only): { hosted, host, login } — the receipt's word.
|
|
100
|
+
const claimed = vouch && typeof vouch.login === 'string' && /^[A-Za-z0-9-]{1,39}$/.test(vouch.login) ? vouch.login : null;
|
|
101
|
+
const who = account && account.github_login ? `@${account.github_login}`
|
|
102
|
+
: claimed ? `@${claimed} (GitHub user ${githubId})` : `GitHub user ${githubId}`;
|
|
91
103
|
const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
|
|
92
104
|
const stamps = [
|
|
93
105
|
['Core', cleanStamp(context.core_version)],
|
|
@@ -97,10 +109,13 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
|
97
109
|
// The hub's own attribution comes FIRST and the sender's words follow as a
|
|
98
110
|
// quote, so text they type can never pose as the footer a reviewer trusts.
|
|
99
111
|
const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
|
|
112
|
+
const vouchLine = !vouch ? []
|
|
113
|
+
: [`- Sender: ${vouch.hosted ? 'verified (a project Cloud Bongos hosts)' : `confirmed by ${vouch.host} (self-hosted, not verified by us)`}`];
|
|
100
114
|
return [
|
|
101
|
-
'Sent from a managed project with `bongos feedback`.',
|
|
115
|
+
vouch ? 'Sent with `bongos feedback`.' : 'Sent from a managed project with `bongos feedback`.',
|
|
102
116
|
`- Project: ${project}`,
|
|
103
117
|
`- Sent by: ${who}`,
|
|
118
|
+
...vouchLine,
|
|
104
119
|
...(stamps ? [`- Running: ${stamps}`] : []),
|
|
105
120
|
'',
|
|
106
121
|
quoted,
|
|
@@ -110,12 +125,15 @@ function feedbackBody({ message, client, account, githubId, context = {} }) {
|
|
|
110
125
|
// fileInstanceFeedback — file one relayed report. `deps.capture` is the
|
|
111
126
|
// `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
|
|
112
127
|
// own rows for the sender. Returns the landing the CLI prints.
|
|
113
|
-
async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
|
|
114
|
-
|
|
115
|
-
|
|
128
|
+
async function fileInstanceFeedback({ client, githubId, message, kind, context, vouch = null }, deps) {
|
|
129
|
+
// A self-hosted project's word cannot borrow a hub account: its owner could
|
|
130
|
+
// name anyone's GitHub id, so the hub's login and builder row stay unread.
|
|
131
|
+
const trusted = !vouch || vouch.hosted;
|
|
132
|
+
const account = trusted ? await deps.getAccount(githubId).catch(() => null) : null;
|
|
133
|
+
const capturedBy = trusted ? await deps.hubBuilderId(githubId).catch(() => null) : null;
|
|
116
134
|
const idea = await deps.capture({
|
|
117
135
|
title: feedbackTitle(message),
|
|
118
|
-
bodyMd: feedbackBody({ message, client, account, githubId, context }),
|
|
136
|
+
bodyMd: feedbackBody({ message, client, account, githubId, context, vouch }),
|
|
119
137
|
kind: FEEDBACK_KINDS[kind],
|
|
120
138
|
capturedBy,
|
|
121
139
|
filerRank: null, // untrusted by design — see the header
|
|
@@ -129,7 +147,90 @@ async function fileInstanceFeedback({ client, githubId, message, kind, context }
|
|
|
129
147
|
: { landed: 'inbox', idea_id: String(idea.id) };
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
// ── the direct door (task 1004591) ───────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const RECEIPT_TIMEOUT_MS = 8000;
|
|
153
|
+
|
|
154
|
+
function sha256Hex(text) {
|
|
155
|
+
return require('node:crypto').createHash('sha256').update(String(text), 'utf8').digest('hex');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// projectOrigin — the caller-named project, accepted only as a bare https origin
|
|
159
|
+
// on a DNS name (no IP literal, no port, no path, no userinfo). Null otherwise.
|
|
160
|
+
function projectOrigin(raw) {
|
|
161
|
+
let u;
|
|
162
|
+
try { u = new URL(String(raw || '')); } catch { return null; }
|
|
163
|
+
if (u.protocol !== 'https:' || u.port || u.username || u.password) return null;
|
|
164
|
+
if (u.pathname !== '/' && u.pathname !== '') return null;
|
|
165
|
+
if (u.search || u.hash) return null;
|
|
166
|
+
const host = u.hostname.toLowerCase();
|
|
167
|
+
if (!host.includes('.') || require('node:net').isIP(host.replace(/^\[|\]$/g, ''))) return null;
|
|
168
|
+
if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
|
|
169
|
+
return { origin: `https://${host}`, host };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// isHostedBy — a project on the hub's own domain (or the hub itself) is one we run.
|
|
173
|
+
function isHostedBy(host, hubHost) {
|
|
174
|
+
const h = String(hubHost || '').toLowerCase();
|
|
175
|
+
return !!h && (host === h || host.endsWith(`.${h}`));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// verifyDirectReport — redeem the receipt at the project and check it matches.
|
|
179
|
+
// Resolves { ok: true, receipt } or { ok: false, reason }; never throws. The
|
|
180
|
+
// address is attacker-named, so the host must resolve to a public address
|
|
181
|
+
// (deps.assertPublicHost) and a redirect is an error, never followed.
|
|
182
|
+
async function verifyDirectReport({ origin, receipt, message, kind }, deps) {
|
|
183
|
+
const target = projectOrigin(origin);
|
|
184
|
+
if (!target) return { ok: false, reason: 'bad_origin' };
|
|
185
|
+
if (!/^[A-Za-z0-9_-]{16,64}$/.test(String(receipt || ''))) return { ok: false, reason: 'bad_receipt' };
|
|
186
|
+
try {
|
|
187
|
+
await deps.assertPublicHost(target.host);
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: false, reason: 'private_host' };
|
|
190
|
+
}
|
|
191
|
+
let body;
|
|
192
|
+
try {
|
|
193
|
+
const res = await (deps.fetchImpl || fetch)(`${target.origin}/api/bongos/feedback/receipt/${receipt}`, {
|
|
194
|
+
headers: { accept: 'application/json' },
|
|
195
|
+
redirect: 'error',
|
|
196
|
+
signal: AbortSignal.timeout(RECEIPT_TIMEOUT_MS),
|
|
197
|
+
});
|
|
198
|
+
if (res.status !== 200) return { ok: false, reason: `receipt_http_${res.status}` };
|
|
199
|
+
body = await res.json();
|
|
200
|
+
} catch {
|
|
201
|
+
return { ok: false, reason: 'receipt_unreachable' };
|
|
202
|
+
}
|
|
203
|
+
if (!body || typeof body !== 'object') return { ok: false, reason: 'receipt_malformed' };
|
|
204
|
+
if (!/^\d{1,20}$/.test(String(body.github_id || ''))) return { ok: false, reason: 'receipt_malformed' };
|
|
205
|
+
if (body.sha256 !== sha256Hex(message)) return { ok: false, reason: 'text_mismatch' };
|
|
206
|
+
if ((body.kind || 'bug') !== kind) return { ok: false, reason: 'kind_mismatch' };
|
|
207
|
+
return { ok: true, target, receipt: body };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// fileDirectReport — the whole after-the-202 half: verify, then file. Returns the
|
|
211
|
+
// landing, or { dropped: reason } for a report the project did not vouch for.
|
|
212
|
+
async function fileDirectReport({ origin, receipt, message, kind, context = {}, hubHost }, deps) {
|
|
213
|
+
const v = await verifyDirectReport({ origin, receipt, message, kind }, deps);
|
|
214
|
+
if (!v.ok) return { dropped: v.reason };
|
|
215
|
+
const githubId = String(v.receipt.github_id);
|
|
216
|
+
const hosted = isHostedBy(v.target.host, hubHost);
|
|
217
|
+
return fileInstanceFeedback({
|
|
218
|
+
client: { name: v.target.host, origin: v.target.origin, client_id: v.target.host },
|
|
219
|
+
githubId,
|
|
220
|
+
message,
|
|
221
|
+
kind,
|
|
222
|
+
// The project's own core version beats whatever the CLI claimed.
|
|
223
|
+
context: { ...context, core_version: v.receipt.core_version || null },
|
|
224
|
+
vouch: { hosted, host: v.target.host, login: v.receipt.github_login },
|
|
225
|
+
}, deps);
|
|
226
|
+
}
|
|
227
|
+
|
|
132
228
|
module.exports = {
|
|
229
|
+
sha256Hex,
|
|
230
|
+
projectOrigin,
|
|
231
|
+
isHostedBy,
|
|
232
|
+
verifyDirectReport,
|
|
233
|
+
fileDirectReport,
|
|
133
234
|
FEEDBACK_KINDS,
|
|
134
235
|
MESSAGE_MAX,
|
|
135
236
|
PER_SENDER_LIMIT,
|
|
@@ -30,9 +30,25 @@ const applicationEcho = require('../application-echo');
|
|
|
30
30
|
// The guild label the echo carries (task 1002303, ADR 0365 D5).
|
|
31
31
|
const guildEngagements = require('../guild-engagements');
|
|
32
32
|
const instanceFeedback = require('../instance-feedback');
|
|
33
|
+
const projectStats = require('../project-stats');
|
|
33
34
|
|
|
34
35
|
// One limiter per hub process for POST /sso/feedback (task 1004462).
|
|
35
36
|
const feedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
37
|
+
// …and two for POST /sso/feedback/direct (task 1004591), which carries no
|
|
38
|
+
// credential: one keyed by the caller's address ALONE, so naming a fresh host on
|
|
39
|
+
// every request buys nothing, and one by the named project and address.
|
|
40
|
+
const directIpLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
41
|
+
const directFeedbackLimiter = instanceFeedback.makeFeedbackLimiter();
|
|
42
|
+
|
|
43
|
+
// The hub's own host: a direct report from it or a subdomain of it comes from a
|
|
44
|
+
// project we run. Best-effort — no host means nothing counts as hosted. Read once.
|
|
45
|
+
let _hubHost = null;
|
|
46
|
+
function hubHost() {
|
|
47
|
+
if (_hubHost === null) {
|
|
48
|
+
try { _hubHost = new URL(api.branding().domains.publicOrigin).hostname.toLowerCase(); } catch { _hubHost = ''; }
|
|
49
|
+
}
|
|
50
|
+
return _hubHost;
|
|
51
|
+
}
|
|
36
52
|
|
|
37
53
|
// Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
|
|
38
54
|
// gate, not the gate itself (task 1002972 grade advisory). The echo already
|
|
@@ -781,5 +797,60 @@ module.exports = function ssoRoutes() {
|
|
|
781
797
|
}
|
|
782
798
|
});
|
|
783
799
|
|
|
800
|
+
// POST /sso/feedback/direct — `bongos feedback` straight from a builder's CLI,
|
|
801
|
+
// with a one-time receipt from their own project (task 1004591). Answers 202 at
|
|
802
|
+
// once so the builder never waits; the receipt is redeemed AFTER, and a report
|
|
803
|
+
// the project does not vouch for is dropped (logged, never filed). Why this
|
|
804
|
+
// proves the sender, and what "hosted" earns: ../instance-feedback.js.
|
|
805
|
+
//
|
|
806
|
+
// rank: public — no session on the hub; the proof is the project's receipt.
|
|
807
|
+
// Capped per caller address (10/h whatever host it names), then per named
|
|
808
|
+
// project (30/h), BEFORE anything is fetched.
|
|
809
|
+
router.post('/sso/feedback/direct', (req, res) => {
|
|
810
|
+
if (validateOrRespond(req, res, {
|
|
811
|
+
origin: { required: true, type: 'string', maxLength: 200 },
|
|
812
|
+
receipt: { required: true, type: 'string', maxLength: 64 },
|
|
813
|
+
message: { required: true, type: 'string', maxLength: instanceFeedback.MESSAGE_MAX },
|
|
814
|
+
kind: { type: 'string', enum: Object.keys(instanceFeedback.FEEDBACK_KINDS) },
|
|
815
|
+
context: {},
|
|
816
|
+
})) return;
|
|
817
|
+
const b = req.body || {};
|
|
818
|
+
const target = instanceFeedback.projectOrigin(b.origin);
|
|
819
|
+
if (!target) return res.fail('bad_origin', 400);
|
|
820
|
+
const message = String(b.message).trim();
|
|
821
|
+
if (!message) return res.fail('message_required', 400);
|
|
822
|
+
// The address's own bucket first (both of its tiers keyed by the address, so
|
|
823
|
+
// no bucket is shared between callers); only an address still under its cap
|
|
824
|
+
// may spend from the named project's bucket — a refused caller costs a real
|
|
825
|
+
// project nothing.
|
|
826
|
+
const ip = String(req.ip || 'unknown');
|
|
827
|
+
let allowed = directIpLimiter.take(ip, 'direct');
|
|
828
|
+
if (allowed.ok) allowed = directFeedbackLimiter.take(target.host, ip);
|
|
829
|
+
if (!allowed.ok) {
|
|
830
|
+
res.set('Retry-After', String(allowed.retryAfter));
|
|
831
|
+
return res.fail('rate_limited', 429, { scope: `feedback-${allowed.scope}`, retry_after_seconds: allowed.retryAfter });
|
|
832
|
+
}
|
|
833
|
+
res.status(202).json({ ok: true, accepted: true });
|
|
834
|
+
const ctx = b.context && typeof b.context === 'object' ? b.context : {};
|
|
835
|
+
instanceFeedback.fileDirectReport({
|
|
836
|
+
origin: target.origin,
|
|
837
|
+
receipt: b.receipt,
|
|
838
|
+
message: b.message,
|
|
839
|
+
kind: b.kind || 'bug',
|
|
840
|
+
context: { cli_version: ctx.cli_version, os: ctx.os },
|
|
841
|
+
hubHost: hubHost(),
|
|
842
|
+
}, {
|
|
843
|
+
assertPublicHost: (host) => projectStats.assertPublicHost(host),
|
|
844
|
+
capture: (...a) => api.resolve('ideas.capture')(...a),
|
|
845
|
+
getAccount: (id) => pi.getAccount(id, { pool }),
|
|
846
|
+
hubBuilderId: async (id) => {
|
|
847
|
+
const { rows } = await pool.query('SELECT id FROM builders WHERE github_id = $1 LIMIT 1', [id]);
|
|
848
|
+
return rows[0] ? rows[0].id : null;
|
|
849
|
+
},
|
|
850
|
+
}).then((r) => {
|
|
851
|
+
if (r && r.dropped) log.warn(`[platform-identity] /sso/feedback/direct dropped from ${target.host}: ${r.dropped}`);
|
|
852
|
+
}, (err) => log.error('[platform-identity] /sso/feedback/direct', err));
|
|
853
|
+
});
|
|
854
|
+
|
|
784
855
|
return router;
|
|
785
856
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.70",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.70",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.70",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9194,5 +9194,10 @@
|
|
|
9194
9194
|
"id": "1004277",
|
|
9195
9195
|
"text": "The projects you build on, with their Leave buttons, now live on your account page. My projects links there."
|
|
9196
9196
|
}
|
|
9197
|
+
],
|
|
9198
|
+
"1.21.70": [
|
|
9199
|
+
{
|
|
9200
|
+
"text": "Reporting a problem with Bongos now works from every project, including ones like Will's that could not send feedback before. It takes a second, needs no extra sign-in, and the Bongos team can see who really sent each report."
|
|
9201
|
+
}
|
|
9197
9202
|
]
|
|
9198
9203
|
}
|
|
@@ -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
|