@bongos/core 1.20.36 → 1.20.38

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 (53) hide show
  1. package/.bongos-core.json +104 -49
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/architecture.md +2 -0
  20. package/docs/copy-inventory.md +9 -9
  21. package/docs/copy-registry.json +9 -9
  22. package/docs/file-map.md +1 -0
  23. package/docs/module-api-changelog.md +4 -0
  24. package/docs/page-readings.json +2 -2
  25. package/docs/recipes/upgrading-the-core.md +2 -0
  26. package/modules/autonomy/db.js +39 -8
  27. package/modules/autonomy/fence.js +26 -4
  28. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  29. package/modules/autonomy/routes/autonomy.js +28 -1
  30. package/modules/hall-ui/public/gate.js +33 -4
  31. package/modules/ideas/routing.js +8 -1
  32. package/modules/platform-identity/instance-feedback.js +142 -0
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso.js +62 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +30 -0
  38. package/scripts/gds/autobongos-run.js +121 -16
  39. package/scripts/gds/feedback-send.js +131 -0
  40. package/scripts/gds/module-assess-tests.js +248 -0
  41. package/scripts/gds/update-channel.js +82 -0
  42. package/src/bongos/routes/feedback.js +110 -0
  43. package/src/bongos/routes.js +2 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/autobongos_fence.mjs +62 -0
  46. package/tests/autobongos_loop.mjs +281 -0
  47. package/tests/autonomy_fence_priority.mjs +211 -0
  48. package/tests/bongos_cli.mjs +7 -1
  49. package/tests/filing_abuse_matrix.mjs +7 -2
  50. package/tests/idea_routing.mjs +1 -1
  51. package/tests/instance_feedback.mjs +327 -0
  52. package/tests/module_assess_tests.mjs +232 -0
  53. package/tests/update_channel_db.mjs +124 -0
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-tests.js — the Tests part of a module's assessment:
3
+ // run a published module version's OWN test suite and record the pass rate as a
4
+ // module_assessment_signals row (task 1003791, ADR 0343; table from core_265).
5
+ //
6
+ // Why this cannot reuse the unit gate. run-unit-tests.js discovers
7
+ // modules/<key>/tests/*.mjs but runs them only when isModuleEnabled(key) — and a
8
+ // catalog module ships default:false (ADR 0107 §6), so every store module's tests
9
+ // are skipped there by construction (the trap task 1003630 documented). This runs
10
+ // the tests straight from the published TARBALL, with no enabled/disabled check at
11
+ // all, so what is measured is exactly the build a buyer would install.
12
+ //
13
+ // Outcomes (core_265's vocabulary — a missing part is never a zero):
14
+ // scored the module has tests and they ran: score = round(100 × files that
15
+ // passed / files run), sample_size = files run. The unit is a test
16
+ // FILE (one process, its exit code), the same unit the core gate uses.
17
+ // no_data the module declares no tests — NOT a fail (the task says the two
18
+ // must not score the same).
19
+ // not_scored the tarball could not be read or verified, so nothing could run.
20
+ //
21
+ // Where the tests run. A module's tests reach into the core by relative path
22
+ // (`../../../docs/…`) and resolve packages from the core's node_modules, so the
23
+ // module is unpacked at the same depth modules/<key>/ sits at:
24
+ // <core root>/.module-assess-<run>/<key>/ — node's walk-up resolution then finds
25
+ // the core with no junctions to create (and so none a cleanup could follow; see
26
+ // worktree.js). Each file runs in its own node process, serially, with a timeout
27
+ // and a stripped environment: no DATABASE_URL, tokens or keys are handed to
28
+ // third-party code. That is a floor, not a sandbox: a test process can still read
29
+ // any file the OS user can (the core's own .env sits two directories up) and reach
30
+ // the network.
31
+ //
32
+ // SO A PUBLISHED MODULE'S TESTS RUN ONLY IN A SEPARATE TESTING ENVIRONMENT
33
+ // (owner, 2026-09-30): never on the control plane, where the secrets live. The
34
+ // store path (recordTestsSignal) refuses unless the process declares it is that
35
+ // environment with MODULE_TEST_SANDBOX=1 — set only on the isolated test box, no
36
+ // secrets on disk, which is its own task. `--dir` stays open: it runs a module
37
+ // from your own checkout, i.e. code you already chose to run.
38
+ //
39
+ // node scripts/gds/module-assess-tests.js <store_module_versions.id> run + record
40
+ // node scripts/gds/module-assess-tests.js --dir modules/<key> dry run on a
41
+ // local module
42
+ // (prints; no DB)
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const crypto = require('node:crypto');
47
+ const { execFile } = require('node:child_process');
48
+ const { isCleanTeardownAbort } = require('./run-unit-tests');
49
+
50
+ const CORE_ROOT = path.resolve(__dirname, '..', '..');
51
+ const PER_FILE_TIMEOUT_MS = 90_000; // the unit gate's own per-file cap
52
+ const SANDBOX_PREFIX = '.module-assess-';
53
+ // The only variables a module's test process inherits. PATH to find tools; the
54
+ // Windows ones node needs to start at all; nothing that names a credential.
55
+ const ENV_KEEP = ['PATH', 'Path', 'SystemRoot', 'SYSTEMROOT', 'windir', 'TEMP', 'TMP', 'TMPDIR', 'COMSPEC', 'PATHEXT'];
56
+
57
+ // The module's test files: tests/*.mjs at the top of tests/, exactly what
58
+ // run-unit-tests.js discovers for a module on disk. `files` is module-relative paths.
59
+ function discoverTestFiles(paths) {
60
+ return paths.filter((p) => /^tests\/[^/]+\.mjs$/.test(p)).sort();
61
+ }
62
+
63
+ function testEnv(sandbox) {
64
+ const env = {};
65
+ for (const k of ENV_KEEP) if (process.env[k] !== undefined) env[k] = process.env[k];
66
+ env.NODE_ENV = 'test';
67
+ env.HOME = sandbox;
68
+ env.USERPROFILE = sandbox;
69
+ return env;
70
+ }
71
+
72
+ // Run one file; resolve { file, passed, timedOut, code, tail }. Never rejects.
73
+ function runFile(file, { cwd, env, timeoutMs, exec = execFile }) {
74
+ return new Promise((resolve) => {
75
+ exec(process.execPath, [file], { cwd, env, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
76
+ (err, stdout, stderr) => {
77
+ const out = String(stdout || '');
78
+ const errText = String(stderr || '');
79
+ // A suite that passed but aborted in libuv teardown on Windows passed (task 1003845).
80
+ const passed = !err || isCleanTeardownAbort(out, errText);
81
+ const timedOut = !!(err && err.killed);
82
+ // Why it failed: the suite's last stdout lines (its verdict) and the HEAD of
83
+ // stderr, where node puts the thrown error — its tail is only the stack and version.
84
+ const lines = (t, n, fromEnd) => { const ls = t.split(/\r?\n/).filter((l) => l.trim()); return (fromEnd ? ls.slice(-n) : ls.slice(0, n)).join('\n'); };
85
+ const tail = passed ? '' : [lines(out, 3, true), lines(errText, 4, false)].filter(Boolean).join('\n').slice(0, 600);
86
+ resolve({ passed, timedOut, code: err ? (err.code ?? null) : 0, tail });
87
+ });
88
+ });
89
+ }
90
+
91
+ // Remove a sandbox this file made. It holds only real files written by placeFiles
92
+ // (never a link), and the name check means a bad argument cannot aim this anywhere else.
93
+ function removeSandbox(dir, root) {
94
+ if (!path.basename(dir).startsWith(SANDBOX_PREFIX) || path.dirname(dir) !== root) {
95
+ throw new Error(`refusing to remove ${dir}: not a module-assess sandbox`);
96
+ }
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+
100
+ function placeFiles(files, target) {
101
+ for (const f of files) {
102
+ const dest = path.join(target, ...f.path.split('/'));
103
+ if (!dest.startsWith(target + path.sep)) throw new Error(`"${f.path}" would land outside the module`);
104
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
105
+ fs.writeFileSync(dest, f.buf);
106
+ }
107
+ }
108
+
109
+ // The gate in front of running module code. `trusted` is only for code you
110
+ // already chose to run (a module in your own checkout, `--dir`). Anything else — a
111
+ // published, third-party module — runs only where the process declares itself the
112
+ // separate testing environment (MODULE_TEST_SANDBOX=1) AND nothing secret is
113
+ // visible: no credential-looking variable in the environment, no .env at the core
114
+ // root. The second half is what stops a mis-set flag on a real server from
115
+ // quietly reopening the hole. Returns null (go) or { code, message }.
116
+ const SECRET_ENV_RE = /(^DATABASE_URL$|^PG(PASSWORD|PASSFILE)$|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|_KEY$)/i;
117
+ function inTestSandbox(env = process.env) {
118
+ return env.MODULE_TEST_SANDBOX === '1';
119
+ }
120
+ function runRefusal({ trusted = false, env = process.env, root = CORE_ROOT } = {}) {
121
+ if (trusted) return null;
122
+ if (!inTestSandbox(env)) {
123
+ return { code: 'not_in_test_sandbox', message: "refusing to run a published module's tests here: they run only in the separate testing environment (MODULE_TEST_SANDBOX=1), never on a server holding secrets" };
124
+ }
125
+ const leaked = Object.keys(env).filter((k) => SECRET_ENV_RE.test(k) && env[k]);
126
+ if (leaked.length || fs.existsSync(path.join(root, '.env'))) {
127
+ return { code: 'secrets_present', message: `refusing: MODULE_TEST_SANDBOX=1 is set but secrets are visible here (${leaked.length ? leaked.sort().join(', ') : '.env at the core root'}) — the testing environment must hold none` };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // Run a module's own tests from its files ([{ path, buf }], module-relative) and
133
+ // return the signal fields: { outcome, score, sample_size, reason, detail }.
134
+ // Pure apart from the sandbox it writes and removes; `exec` is injectable for tests.
135
+ async function assessTests(key, files, { root = CORE_ROOT, timeoutMs = PER_FILE_TIMEOUT_MS, exec, trusted = false, env = process.env } = {}) {
136
+ // Checked here too, not only in recordTestsSignal, so no caller can reach the
137
+ // code that runs a module without passing the gate.
138
+ const refused = runRefusal({ trusted, env, root });
139
+ if (refused) throw Object.assign(new Error(refused.message), { code: refused.code });
140
+ const tests = discoverTestFiles(files.map((f) => f.path));
141
+ if (tests.length === 0) {
142
+ return { outcome: 'no_data', score: null, sample_size: 0, reason: 'the module declares no tests (no tests/*.mjs)', detail: { files: [] } };
143
+ }
144
+ const sandbox = path.join(root, `${SANDBOX_PREFIX}${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
145
+ const moduleDir = path.join(sandbox, key);
146
+ try {
147
+ placeFiles(files, moduleDir);
148
+ const env = testEnv(sandbox);
149
+ const results = [];
150
+ for (const t of tests) {
151
+ // Serial on purpose, like the unit gate: tests share the core tree around them.
152
+ const r = await runFile(path.join(moduleDir, ...t.split('/')), { cwd: moduleDir, env, timeoutMs, exec });
153
+ results.push({ file: t, ...r });
154
+ }
155
+ const passed = results.filter((r) => r.passed).length;
156
+ const failed = results.filter((r) => !r.passed);
157
+ return {
158
+ outcome: 'scored',
159
+ score: Math.round((100 * passed) / results.length),
160
+ sample_size: results.length,
161
+ reason: failed.length ? `${failed.length} of ${results.length} test file(s) failed` : `all ${results.length} test file(s) passed`,
162
+ detail: { files: results.map(({ file, passed: ok, timedOut, tail }) => ({ file, passed: ok, ...(timedOut ? { timed_out: true } : {}), ...(tail ? { tail } : {}) })) },
163
+ };
164
+ } finally {
165
+ removeSandbox(sandbox, root);
166
+ }
167
+ }
168
+
169
+ // Append one Tests signal row for a store version. Never UPDATEs (core_265 is
170
+ // append-only: the current result is the newest row per (version, part)).
171
+ async function insertSignal(versionId, s, { db }) {
172
+ const { rows: [row] } = await db.query(
173
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
174
+ VALUES ($1, 'tests', $2, $3, $4, $5, $6)
175
+ RETURNING id, version_id, part, outcome, score, sample_size, reason, measured_at`,
176
+ [versionId, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail || {})]);
177
+ return row;
178
+ }
179
+
180
+ // Read, verify and test one published version, then record the result. A tarball
181
+ // that is missing or fails verification is recorded as not_scored — the part could
182
+ // not be measured, which is not the same as the module failing its tests.
183
+ async function recordTestsSignal(versionId, { db, storeDir, root, timeoutMs, exec, env = process.env } = {}) {
184
+ const refused = runRefusal({ env, root });
185
+ if (refused) return { ok: false, ...refused };
186
+ const pool = db || require('../../src/bongos/pool').pool;
187
+ const { versionArtifactFile } = require('../../src/bongos/module-store');
188
+ const { verifyModuleArtifact } = require('./module-artifact');
189
+ const { rows: [ver] } = await pool.query(
190
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
191
+ if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
192
+
193
+ let signal;
194
+ try {
195
+ const file = versionArtifactFile(ver, storeDir ? { dir: storeDir } : {});
196
+ const tgz = await fs.promises.readFile(file);
197
+ const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
198
+ signal = v.ok
199
+ ? await assessTests(ver.module_key, v.files, { root, timeoutMs, exec, env })
200
+ : { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
201
+ } catch (e) {
202
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} };
203
+ }
204
+ const row = await insertSignal(ver.id, signal, { db: pool });
205
+ return { ok: true, version: ver, signal: row };
206
+ }
207
+
208
+ // Local dry run: read modules/<key>/ from disk and print what the signal would be.
209
+ function readModuleDir(dir) {
210
+ const out = [];
211
+ const walk = (rel) => {
212
+ for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
213
+ if (e.name === 'node_modules' || e.name === '.git') continue;
214
+ const r = rel ? `${rel}/${e.name}` : e.name;
215
+ if (e.isDirectory()) walk(r);
216
+ else if (e.isFile()) out.push({ path: r, buf: fs.readFileSync(path.join(dir, r)) });
217
+ }
218
+ };
219
+ walk('');
220
+ return out;
221
+ }
222
+
223
+ async function main(argv) {
224
+ const i = argv.indexOf('--dir');
225
+ if (i !== -1) {
226
+ const dir = path.resolve(argv[i + 1] || '');
227
+ const key = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8')).key;
228
+ const s = await assessTests(key, readModuleDir(dir), { trusted: true });
229
+ console.log(JSON.stringify(s, null, 2));
230
+ return 0;
231
+ }
232
+ const id = argv[0];
233
+ if (!/^\d+$/.test(String(id || ''))) {
234
+ console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key>');
235
+ return 2;
236
+ }
237
+ const res = await recordTestsSignal(id);
238
+ if (!res.ok) { console.error(res.message); return 1; }
239
+ const s = res.signal;
240
+ console.log(`${res.version.module_key} ${res.version.version}: tests ${s.outcome}${s.score !== null ? ` ${s.score}/100` : ''} — ${s.reason} (signal ${s.id})`);
241
+ return 0;
242
+ }
243
+
244
+ if (require.main === module) {
245
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
246
+ }
247
+
248
+ module.exports = { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
@@ -228,6 +228,82 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
228
228
  };
229
229
  }
230
230
 
231
+ // ---- the per-project rule set on /deploy (task 1004468, ADR 0357) -----------
232
+ // The owner sets each project's update rule on /deploy, which writes
233
+ // provisioning_instances.update_channel (migration provisioning_029, task 1004445). That column
234
+ // is the rule; the roster's `channel` is only the fallback for an entry no project row matches,
235
+ // or for a sweep that cannot reach the database. Before this, the button followed the column and
236
+ // the sweep followed the roster, so a project set to `minor` on /deploy was still swept as `patch`.
237
+ //
238
+ // Matched by slug — the one stable key the table has (it records no directory). Read over psql,
239
+ // which is how upgrade.js recordLedger reaches a PGDATABASE (a bare name over the unix socket +
240
+ // peer auth, the self-host shape). recordLedger uses the pg driver for DATABASE_URL; this passes
241
+ // the URL to `psql -d` instead, because the sweep is synchronous and pg is not. With no psql the
242
+ // sweep falls back to the roster and says so.
243
+ //
244
+ // A failure's reason is a fixed class, never psql's own text: stderr can name a DB user or host,
245
+ // and the reason is printed into the sweep's log.
246
+ const CHANNEL_SOURCE_DEPLOY = 'deploy';
247
+ const CHANNEL_SOURCE_ROSTER = 'roster';
248
+ const DB_CHANNELS_SQL = 'SELECT slug, update_channel FROM provisioning_instances';
249
+ const DB_CHANNELS_TIMEOUT_MS = 15000;
250
+
251
+ // PURE. psql's stderr reduced to a class that is safe to log.
252
+ function psqlFailureClass(stderr) {
253
+ const s = String(stderr || '');
254
+ if (/relation .*provisioning_instances.* does not exist/i.test(s)) return 'no provisioning_instances table (provisioning module off?)';
255
+ if (/column .*update_channel.* does not exist/i.test(s)) return 'no update_channel column (migration provisioning_029 not applied)';
256
+ if (/authentication failed|role .* does not exist|permission denied/i.test(s)) return 'the sweep\'s database login was refused';
257
+ if (/could not connect|connection refused|no such file or directory|could not translate host/i.test(s)) return 'database unreachable';
258
+ if (/database .* does not exist/i.test(s)) return 'the named database does not exist';
259
+ return 'query failed';
260
+ }
261
+
262
+ // The line a sweep prints for one entry's channel. PURE.
263
+ function channelLine(inst) {
264
+ return ` • ${inst.slug}: channel ${inst.channel}, ${inst.channelNote}`;
265
+ }
266
+
267
+ // Read every project's update rule. Never throws: { ok:true, channels: Map<slug, channel> }, or
268
+ // { ok:false, reason } when there is no database to ask, psql is missing, or the table is absent
269
+ // (an instance with the provisioning module off). The caller falls back to the roster and says why.
270
+ function readDbChannels({ env = process.env, run = spawnSync } = {}) {
271
+ const target = env.DATABASE_URL || env.PGDATABASE;
272
+ if (!target) return { ok: false, reason: 'no DATABASE_URL or PGDATABASE set for the sweep' };
273
+ const r = run('psql', ['-d', target, '-At', '-F', '\t', '-v', 'ON_ERROR_STOP=1', '--no-psqlrc', '-c', DB_CHANNELS_SQL],
274
+ { encoding: 'utf8', timeout: DB_CHANNELS_TIMEOUT_MS });
275
+ if (!r || r.error) {
276
+ const code = r && r.error && r.error.code;
277
+ return { ok: false, reason: code === 'ENOENT' ? 'psql not found' : code === 'ETIMEDOUT' ? 'psql timed out' : 'psql could not be started' };
278
+ }
279
+ if (r.status !== 0) return { ok: false, reason: `psql exit ${r.status}: ${psqlFailureClass(r.stderr)}` };
280
+ const channels = new Map();
281
+ for (const line of String(r.stdout || '').split('\n')) {
282
+ const [slug, ch] = line.split('\t');
283
+ if (slug && ch) channels.set(slug.trim(), ch.trim());
284
+ }
285
+ return { ok: true, channels };
286
+ }
287
+
288
+ // PURE. Give each roster entry the channel it is actually under, and say where it came from:
289
+ // `channelSource` is 'deploy' (the project row) or 'roster', `channelNote` the one-line reason a
290
+ // sweep log prints. The roster's own value is kept as `rosterChannel`, so a disagreement stays
291
+ // visible in the log instead of silently vanishing.
292
+ function applyDbChannels(instances, db) {
293
+ return instances.map((inst) => {
294
+ if (db && db.ok && db.channels.has(inst.slug)) {
295
+ const ch = normalizeChannel(db.channels.get(inst.slug));
296
+ const differs = ch !== inst.channel;
297
+ return {
298
+ ...inst, channel: ch, channelSource: CHANNEL_SOURCE_DEPLOY, rosterChannel: inst.channel,
299
+ channelNote: `set on /deploy${differs ? ` (roster says ${inst.channel}, ignored)` : ''}`,
300
+ };
301
+ }
302
+ const why = db && db.ok ? 'no project row for this slug' : (db && db.reason) || 'database not read';
303
+ return { ...inst, channelSource: CHANNEL_SOURCE_ROSTER, rosterChannel: inst.channel, channelNote: `from the roster — ${why}` };
304
+ });
305
+ }
306
+
231
307
  // ---- reporting: make "nothing happened" audible -----------------------------
232
308
 
233
309
  // Turn a loadSubscriptions() result into the line a routine prints EVERY run, plus a level a caller
@@ -322,6 +398,12 @@ module.exports = {
322
398
  listAvailableVersions,
323
399
  parseNpmViewVersions,
324
400
  loadSubscriptions,
401
+ readDbChannels,
402
+ applyDbChannels,
403
+ channelLine,
404
+ psqlFailureClass,
405
+ CHANNEL_SOURCE_DEPLOY,
406
+ CHANNEL_SOURCE_ROSTER,
325
407
  ROSTER_OK,
326
408
  ROSTER_ABSENT,
327
409
  ROSTER_MALFORMED,
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/routes/feedback.js — POST /api/bongos/feedback (task 1004462).
4
+ //
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.
10
+ //
11
+ // Only the text the builder typed and version stamps travel. No code, transcript,
12
+ // task or file content from this project is attached.
13
+ //
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.
18
+
19
+ const express = require('express');
20
+ const { asyncHandler, validateOrRespond } = require('./_helpers');
21
+ const auth = require('../auth');
22
+ const { loadIdpConfig } = require('../auth-config');
23
+ const { userAgent } = require('../../branding');
24
+
25
+ const MESSAGE_MAX = 4000;
26
+ const HUB_TIMEOUT_MS = 15000;
27
+
28
+ function safeIdpConfig() {
29
+ try { return loadIdpConfig(); } catch { return null; }
30
+ }
31
+
32
+ function runningCore() {
33
+ try { return require('../../module-api').CORE_VERSION || null; } catch { return null; }
34
+ }
35
+
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 };
57
+ }
58
+
59
+ function buildFeedbackRouter({ idpConfig = safeIdpConfig, fetchImpl = fetch } = {}) {
60
+ const router = express.Router();
61
+
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.
64
+ router.post(
65
+ '/feedback',
66
+ auth.requireBuilder,
67
+ asyncHandler('POST /feedback', async (req, res) => {
68
+ if (validateOrRespond(req, res, {
69
+ message: { required: true, type: 'string', maxLength: MESSAGE_MAX },
70
+ kind: { type: 'string', enum: ['bug', 'idea'] },
71
+ context: {},
72
+ })) 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 = {
83
+ 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 }),
105
+ );
106
+
107
+ return router;
108
+ }
109
+
110
+ module.exports = buildFeedbackRouter;
@@ -20,6 +20,7 @@ const express = require('express');
20
20
  const buildHealthzRouter = require('./routes/healthz');
21
21
  const buildInstanceRouter = require('./routes/instance');
22
22
  const buildCoreUpdateRouter = require('./routes/core-update');
23
+ const buildFeedbackRouter = require('./routes/feedback');
23
24
  const buildAuthRouter = require('./routes/auth');
24
25
  const buildGithubRouter = require('./routes/github');
25
26
  const buildMeRouter = require('./routes/me');
@@ -144,6 +145,7 @@ function buildGdsRouter() {
144
145
  router.use(buildHealthzRouter());
145
146
  router.use(buildInstanceRouter());
146
147
  router.use(buildCoreUpdateRouter());
148
+ router.use(buildFeedbackRouter());
147
149
  router.use(buildAuthRouter());
148
150
  router.use(buildGithubRouter());
149
151
  router.use(buildMeRouter());
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.20.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.38'; // 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');
@@ -260,3 +260,65 @@ test('no request means every allowlisted goal', () => {
260
260
  assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
261
261
  assert.deepEqual(allowedGoals(f).goals, [1, 2]);
262
262
  });
263
+
264
+ // ── the priority goal (task 1004453) ────────────────────────────────────────────
265
+ //
266
+ // Owner ruling: whoever starts the runner names the goal it works FIRST, as well as
267
+ // the set it may touch. The priority ORDERS the allowlist; it must never widen it.
268
+
269
+ test('the priority goal is read off the fence and moved to the front', () => {
270
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 });
271
+ assert.equal(normaliseFence(f).priorityGoal, 3);
272
+ const d = decideRun({ fence: f, ...GRADER_OK });
273
+ assert.equal(d.go, true);
274
+ assert.deepEqual(d.goals, [3, 1, 2], 'the runner walks goals in this order, so the priority goes first');
275
+ assert.equal(d.priorityGoal, 3);
276
+ });
277
+
278
+ test('a priority that is not on the allowlist is ignored, never added', () => {
279
+ // The database refuses this with a foreign key; the pure half refuses it again,
280
+ // because a priority that could add a goal would be a second, unguarded allowlist.
281
+ const f = open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: 99 });
282
+ assert.equal(normaliseFence(f).priorityGoal, null);
283
+ const d = decideRun({ fence: f, ...GRADER_OK });
284
+ assert.deepEqual(d.goals, [1, 2]);
285
+ assert.equal(d.priorityGoal, null);
286
+ });
287
+
288
+ test('a malformed or missing priority reads as "no priority", not as a refusal', () => {
289
+ for (const p of [undefined, null, '', 0, -4, 'abc', 1.5, {}]) {
290
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: p }), ...GRADER_OK });
291
+ assert.equal(d.go, true, `priority ${JSON.stringify(p)} must not stop the run`);
292
+ assert.deepEqual(d.goals, [1, 2], `priority ${JSON.stringify(p)} must not reorder`);
293
+ assert.equal(d.priorityGoal, null);
294
+ }
295
+ });
296
+
297
+ test('a stringified priority id (a bigint from the database) still counts', () => {
298
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }], priority_goal_id: '2' }), ...GRADER_OK });
299
+ assert.deepEqual(d.goals, [2, 1]);
300
+ assert.equal(d.priorityGoal, 2);
301
+ });
302
+
303
+ test('narrowing on the command line keeps the priority first, and can drop it', () => {
304
+ const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }));
305
+ assert.deepEqual(allowedGoals(f, [1, 3]).goals, [3, 1]);
306
+ // An operator narrowing to goals that leave the priority out gets no priority —
307
+ // it is not quietly re-added behind their back.
308
+ assert.deepEqual(allowedGoals(f, [1, 2]).goals, [1, 2]);
309
+ const d = decideRun({ fence: open({ goals: [{ goal_id: 1 }, { goal_id: 2 }, { goal_id: 3 }], priority_goal_id: 3 }), requestedGoals: [1, 2], ...GRADER_OK });
310
+ assert.equal(d.priorityGoal, null, 'a row must not claim a pick came from a priority this run excluded');
311
+ });
312
+
313
+ test('prioritise only reorders: same members, same count', () => {
314
+ assert.deepEqual(fence.prioritise([5, 6, 7], 6), [6, 5, 7]);
315
+ assert.deepEqual(fence.prioritise([5, 6, 7], 8), [5, 6, 7]);
316
+ assert.deepEqual(fence.prioritise([5, 6, 7], null), [5, 6, 7]);
317
+ });
318
+
319
+ test('a closed fence still refuses whatever the priority says', () => {
320
+ const d = decideRun({ fence: open({ enabled: false, priority_goal_id: 1000119 }), ...GRADER_OK });
321
+ assert.equal(d.go, false);
322
+ assert.equal(d.code, 'kill_switch');
323
+ assert.deepEqual(d.goals, []);
324
+ });