@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.
@@ -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.69'; // 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');