@bongos/core 1.19.337 → 1.19.338

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.
@@ -23,6 +23,9 @@ const { execFile } = require('node:child_process');
23
23
  const auth = require('../auth');
24
24
  const { branding } = require('../../branding');
25
25
  const { asyncHandler } = require('./_helpers');
26
+ // task 1003208: structured logging — was console.*. A core file logs through
27
+ // the kernel logger directly; only MODULES go via api.logger (the doorway).
28
+ const log = require('../logger').logger.child({ src: 'backup' });
26
29
 
27
30
  const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
28
31
  const BACKUP_DIR = process.env.GDS_BACKUP_DIR || '/var/backups/gds';
@@ -132,15 +135,15 @@ module.exports = function buildBackupRouter() {
132
135
  triggerRunning = true;
133
136
  const startedAt = new Date().toISOString();
134
137
  const builderId = req.builder && req.builder.id;
135
- console.log(`[gds] backup triggered by builder ${builderId} at ${startedAt}`);
138
+ log.info(`[gds] backup triggered by builder ${builderId} at ${startedAt}`);
136
139
 
137
140
  execFile('bash', [BACKUP_SCRIPT], { cwd: REPO_ROOT, timeout: 120_000 }, (err, stdout, stderr) => {
138
141
  triggerRunning = false;
139
142
  if (err) {
140
- console.error(`[gds] backup trigger failed (builder ${builderId}):`, err.message);
141
- if (stderr) console.error('[gds] backup stderr:', stderr.toString().trim());
143
+ log.error(`[gds] backup trigger failed (builder ${builderId}):`, err.message);
144
+ if (stderr) log.error('[gds] backup stderr:', stderr.toString().trim());
142
145
  } else {
143
- console.log(`[gds] backup trigger completed (builder ${builderId})`);
146
+ log.info(`[gds] backup trigger completed (builder ${builderId})`);
144
147
  }
145
148
  });
146
149
 
@@ -10,6 +10,9 @@ const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
10
10
  const { pool } = require('../pool');
11
11
  const seams = require('../../module-seams');
12
12
  const { branding } = require('../../branding');
13
+ // task 1003208: structured logging — was console.*. A core file logs through
14
+ // the kernel logger directly; only MODULES go via api.logger (the doorway).
15
+ const log = require('../logger').logger.child({ src: 'builders' });
13
16
  // The lifecycle (tasks · claims · ship) carved to modules/lifecycle/ (BV1.R86 /
14
17
  // ADR 0093 §3): the Archon roster, the per-builder stale-claim view, the
15
18
  // per-builder grade breakdown, and the shipped-count read resolve the `lifecycle`
@@ -89,7 +92,7 @@ function buildRankChangeBroadcast({ displayName, direction, newRank }) {
89
92
  function fireDiscordBroadcast(text) {
90
93
  if (!text) return;
91
94
  if (process.env.CHAT_DRY_RUN === '1') {
92
- console.log(`[gds] [broadcast dry-run] would post: ${text}`);
95
+ log.info(`[gds] [broadcast dry-run] would post: ${text}`);
93
96
  return;
94
97
  }
95
98
  try {
@@ -99,13 +102,13 @@ function fireDiscordBroadcast(text) {
99
102
  { cwd: REPO_ROOT, timeout: 10_000 },
100
103
  (err, _stdout, stderr) => {
101
104
  if (err) {
102
- console.log('[gds] rank-change broadcast failed (non-blocking):', err.message);
103
- if (stderr) console.log('[gds] discord stderr:', stderr.toString().trim());
105
+ log.info('[gds] rank-change broadcast failed (non-blocking):', err.message);
106
+ if (stderr) log.info('[gds] discord stderr:', stderr.toString().trim());
104
107
  }
105
108
  }
106
109
  );
107
110
  } catch (e) {
108
- console.log('[gds] rank-change broadcast spawn failed (non-blocking):', e && e.message);
111
+ log.info('[gds] rank-change broadcast spawn failed (non-blocking):', e && e.message);
109
112
  }
110
113
  }
111
114
 
@@ -182,7 +185,7 @@ module.exports = function buildBuildersRouter() {
182
185
  if (err && err.code === 'MULTI_RUNG_PROMOTION') {
183
186
  return res.fail('multi_rung_promotion', { status: 400, message: `Promotions advance one rung at a time. From ${err.from}, the next rung is ${err.next_rung} — promote there first.`, details: { from: err.from, to: err.to, next_rung: err.next_rung } });
184
187
  }
185
- console.error('[gds] PATCH /builders/:id/rank', err);
188
+ log.error('[gds] PATCH /builders/:id/rank', err);
186
189
  res.fail('rank_change_failed', { status: 500, message: 'internal error' });
187
190
  }
188
191
  });
@@ -233,7 +236,7 @@ module.exports = function buildBuildersRouter() {
233
236
  if (err && err.code === 'NOT_INACTIVE') {
234
237
  return res.fail('not_inactive', 409, { status: err.status });
235
238
  }
236
- console.error('[gds] PATCH /builders/:id/status', err);
239
+ log.error('[gds] PATCH /builders/:id/status', err);
237
240
  res.fail('status_change_failed', { status: 500, message: 'internal error' });
238
241
  }
239
242
  });
@@ -268,7 +271,7 @@ module.exports = function buildBuildersRouter() {
268
271
  if (!updated) return res.fail('builder_not_found', 404);
269
272
  res.json({ builder: updated });
270
273
  } catch (err) {
271
- console.error('[gds] PATCH /builders/:id/budget', err);
274
+ log.error('[gds] PATCH /builders/:id/budget', err);
272
275
  res.fail('budget_change_failed', { status: 500, message: 'internal error' });
273
276
  }
274
277
  });
@@ -301,7 +304,7 @@ module.exports = function buildBuildersRouter() {
301
304
  const builders = await lc().getBuildersRoster({ staleHours });
302
305
  res.json({ builders, stale_hours: staleHours });
303
306
  } catch (err) {
304
- console.error('[gds] GET /builders/roster', err);
307
+ log.error('[gds] GET /builders/roster', err);
305
308
  res.fail('roster_failed', { status: 500, message: 'internal error' });
306
309
  }
307
310
  });
@@ -319,7 +322,7 @@ module.exports = function buildBuildersRouter() {
319
322
  const builders = await db.listActiveBuilders();
320
323
  res.json({ builders });
321
324
  } catch (err) {
322
- console.error('[gds] GET /builders/directory', err);
325
+ log.error('[gds] GET /builders/directory', err);
323
326
  res.fail('directory_failed', { status: 500, message: 'internal error' });
324
327
  }
325
328
  });
@@ -348,7 +351,7 @@ module.exports = function buildBuildersRouter() {
348
351
  const builders = await lc().gradeByBuilder(days);
349
352
  res.json({ window_days: days, builders });
350
353
  } catch (err) {
351
- console.error('[gds] GET /grades/by-builder', err);
354
+ log.error('[gds] GET /grades/by-builder', err);
352
355
  res.fail('grades_by_builder_failed', { status: 500, message: 'internal error' });
353
356
  }
354
357
  });
@@ -377,7 +380,7 @@ module.exports = function buildBuildersRouter() {
377
380
  const claims = await lc().getActiveClaimsForBuilderWithStale(targetId, { staleHours });
378
381
  res.json({ builder_id: targetId, claims, stale_hours: staleHours });
379
382
  } catch (err) {
380
- console.error('[gds] GET /builders/:id/active-claims', err);
383
+ log.error('[gds] GET /builders/:id/active-claims', err);
381
384
  res.fail('active_claims_failed', { status: 500, message: 'internal error' });
382
385
  }
383
386
  });
@@ -432,7 +435,7 @@ module.exports = function buildBuildersRouter() {
432
435
  res.set('Cache-Control', 'private, max-age=10');
433
436
  res.json({ builder_id: targetId, onboarding: shaped });
434
437
  } catch (err) {
435
- console.error('[gds] GET /builders/:id/onboarding', err);
438
+ log.error('[gds] GET /builders/:id/onboarding', err);
436
439
  res.fail('onboarding_read_failed', { status: 500, message: 'internal error' });
437
440
  }
438
441
  });
@@ -513,7 +516,7 @@ module.exports = function buildBuildersRouter() {
513
516
  achievements,
514
517
  });
515
518
  } catch (err) {
516
- console.error('[gds] GET /builders/:id/profile', err);
519
+ log.error('[gds] GET /builders/:id/profile', err);
517
520
  res.fail('profile_read_failed', { status: 500, message: 'internal error' });
518
521
  }
519
522
  });
@@ -549,7 +552,7 @@ module.exports = function buildBuildersRouter() {
549
552
  });
550
553
  res.json({ change, greeting });
551
554
  } catch (err) {
552
- console.error('[gds] GET /builders/me/pending-rank-change', err);
555
+ log.error('[gds] GET /builders/me/pending-rank-change', err);
553
556
  res.fail('pending_rank_change_failed', { status: 500, message: 'internal error' });
554
557
  }
555
558
  });
@@ -19,6 +19,9 @@ const { clientBranding, branding } = require('../../branding');
19
19
  const { clientModules } = require('../../modules');
20
20
  const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
21
21
  const { parseId, corsPublicGet, parsePagination, pageMeta } = require('./_helpers');
22
+ // task 1003208: structured logging — was console.*. A core file logs through
23
+ // the kernel logger directly; only MODULES go via api.logger (the doorway).
24
+ const log = require('../logger').logger.child({ src: 'public' });
22
25
 
23
26
  // Pure projection for the single-task resolver. Exported for unit tests.
24
27
  // Privacy gate: a full title/value_summary is exposed ONLY for already-public
@@ -282,7 +285,7 @@ module.exports = function buildPublicRouter() {
282
285
  res.set('Cache-Control', 'public, max-age=30');
283
286
  res.json({ services: result });
284
287
  } catch (err) {
285
- console.error('[gds] GET /public/uptime', err);
288
+ log.error('[gds] GET /public/uptime', err);
286
289
  res.fail('uptime_failed', 500);
287
290
  }
288
291
  });
@@ -355,7 +358,7 @@ module.exports = function buildPublicRouter() {
355
358
  trend: trendRows.rows,
356
359
  });
357
360
  } catch (err) {
358
- console.error('[gds] GET /public/repo-health', err);
361
+ log.error('[gds] GET /public/repo-health', err);
359
362
  res.fail('repo_health_failed', 500);
360
363
  }
361
364
  });
@@ -394,7 +397,7 @@ module.exports = function buildPublicRouter() {
394
397
  res.set('Cache-Control', 'public, max-age=60');
395
398
  res.json({ task: projectPublicTask(task) });
396
399
  } catch (err) {
397
- console.error('[gds] GET /public/tasks/:id', err);
400
+ log.error('[gds] GET /public/tasks/:id', err);
398
401
  res.fail('task_resolve_failed', 500);
399
402
  }
400
403
  });
@@ -415,7 +418,7 @@ module.exports = function buildPublicRouter() {
415
418
  res.set('Cache-Control', 'public, max-age=60');
416
419
  res.json({ idea: projectPublicIdea(rows[0]) });
417
420
  } catch (err) {
418
- console.error('[gds] GET /public/ideas/:id', err);
421
+ log.error('[gds] GET /public/ideas/:id', err);
419
422
  res.fail('idea_resolve_failed', 500);
420
423
  }
421
424
  });
@@ -432,7 +435,7 @@ module.exports = function buildPublicRouter() {
432
435
  res.set('Cache-Control', 'public, max-age=60');
433
436
  res.json({ blocker: projectPublicBlocker(rows[0]) });
434
437
  } catch (err) {
435
- console.error('[gds] GET /public/blockers/:id', err);
438
+ log.error('[gds] GET /public/blockers/:id', err);
436
439
  res.fail('blocker_resolve_failed', 500);
437
440
  }
438
441
  });
@@ -461,7 +464,7 @@ module.exports = function buildPublicRouter() {
461
464
  blockers: blockers.rows.map((r) => Number(r.id)),
462
465
  });
463
466
  } catch (err) {
464
- console.error('[gds] GET /public/ref-ids', err);
467
+ log.error('[gds] GET /public/ref-ids', err);
465
468
  res.fail('ref_ids_failed', 500);
466
469
  }
467
470
  });
@@ -10,6 +10,9 @@
10
10
 
11
11
  const http = require('node:http');
12
12
  const { pool } = require('./pool');
13
+ // task 1003208: structured logging — was console.*. A core file logs through
14
+ // the kernel logger directly; only MODULES go via api.logger (the doorway).
15
+ const log = require('./logger').logger.child({ src: 'uptime-poller' });
13
16
 
14
17
  const INTERVAL_MS = 60_000;
15
18
  const TIMEOUT_MS = 8_000;
@@ -65,26 +68,26 @@ async function runChecks() {
65
68
  [svc.name, status, latency ?? null]
66
69
  );
67
70
  } catch (err) {
68
- console.error(`[uptime] check failed for ${svc.name}:`, err.message);
71
+ log.error(`[uptime] check failed for ${svc.name}:`, err.message);
69
72
  }
70
73
  }
71
74
 
72
75
  // Prune old rows (fire-and-forget)
73
76
  pool.query(
74
77
  `DELETE FROM uptime_checks WHERE checked_at < now() - interval '${PRUNE_DAYS} days'`
75
- ).catch((err) => console.error('[uptime] prune error:', err.message));
78
+ ).catch((err) => log.error('[uptime] prune error:', err.message));
76
79
  }
77
80
 
78
81
  function start() {
79
82
  // Stagger first check by 10s so the server finishes binding before we hit it
80
83
  setTimeout(() => {
81
- runChecks().catch((err) => console.error('[uptime] initial check error:', err.message));
84
+ runChecks().catch((err) => log.error('[uptime] initial check error:', err.message));
82
85
  setInterval(() => {
83
- runChecks().catch((err) => console.error('[uptime] check error:', err.message));
86
+ runChecks().catch((err) => log.error('[uptime] check error:', err.message));
84
87
  }, INTERVAL_MS);
85
88
  }, 10_000);
86
89
 
87
- console.log('[uptime] poller started — checking every 60s');
90
+ log.info('[uptime] poller started — checking every 60s');
88
91
  }
89
92
 
90
93
  module.exports = { start };
package/src/module-api.js CHANGED
@@ -19,6 +19,9 @@
19
19
  // modules behind kernel-composed auth is BV1.R41 / task 1410.
20
20
 
21
21
  const auth = require('./bongos/auth');
22
+ const util = require('node:util');
23
+ // task 1003208: api.logger() returns a real pino child from here (see below).
24
+ const { logger: coreLogger } = require('./bongos/logger');
22
25
  const { pool, instanceDbName } = require('./bongos/pool');
23
26
  const branding = require('./branding');
24
27
  const instanceConfig = require('./instance-config');
@@ -1101,18 +1104,51 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1101
1104
  // landed since 1.19.335 with no explicit bump. run 32795564021. (task 1002620)
1102
1105
  // 1.19.337 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1103
1106
  // landed since 1.19.336 with no explicit bump. run 32795896926. (task 1002620)
1107
+ // 1.19.338 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1108
+ // landed since 1.19.337 with no explicit bump. run 32796733697. (task 1002620)
1104
1109
  // ---------------------------------------------------------------------------
1105
- const CORE_VERSION = '1.19.337'; // CI auto-patch carrier (ADR 0161); see changelog above
1110
+ const CORE_VERSION = '1.19.338'; // CI auto-patch carrier (ADR 0161); see changelog above
1106
1111
 
1107
1112
  // A namespaced logger so a module's log lines are attributable + consistent.
1108
1113
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
1114
+ // task 1003208 (goal 1000079): this USED TO BE `console.log(tag, ...a)`. Every
1115
+ // module that had "adopted the logger" was therefore still writing unstructured
1116
+ // text to stdout with no request correlation and no redaction — the criterion's
1117
+ // complaint ("used across the server paths, not just installed") was true even of
1118
+ // the files that looked migrated. It now returns a real pino child from
1119
+ // src/bongos/logger.js, so every existing call site became structured,
1120
+ // correlated and redacted without one of them changing.
1121
+ //
1122
+ // CONSOLE-COMPATIBLE ON PURPOSE. Call sites were written against console's
1123
+ // signature — `log.warn('failed:', err.message)` — and pino's is
1124
+ // `(mergingObject, message)`, which silently DROPS console-style extra args. So:
1125
+ // * OBJECT arguments are merged into the record, never formatted into the
1126
+ // message. That is what makes redaction work: pino censors by FIELD PATH, so
1127
+ // `log.info('token was', { token: 'abc' })` logs token:"[Redacted]", where
1128
+ // flattening to a string would have written the secret out verbatim.
1129
+ // * everything else goes through util.format — exactly what console does — so
1130
+ // the message text is unchanged.
1131
+ // * an Error is passed as `err` so pino serialises its stack and type.
1132
+ // A secret INTERPOLATED into a message string is still not redactable here; that
1133
+ // is the scrubber's and the prevent hook's job, as logger.js's own header says.
1109
1134
  function logger(moduleKey) {
1110
- const tag = `[${moduleKey || 'module'}]`;
1111
- return {
1112
- info: (...a) => console.log(tag, ...a),
1113
- warn: (...a) => console.warn(tag, ...a),
1114
- error: (...a) => console.error(tag, ...a),
1135
+ const child = coreLogger.child({ module: moduleKey || 'module' });
1136
+ const emit = (level) => (...args) => {
1137
+ if (args.length === 0) return child[level]('');
1138
+ const bindings = {};
1139
+ const rest = [];
1140
+ let hasBindings = false;
1141
+ for (const a of args) {
1142
+ if (a instanceof Error) { bindings.err = a; hasBindings = true; continue; }
1143
+ if (a !== null && typeof a === 'object' && !Array.isArray(a)) {
1144
+ Object.assign(bindings, a); hasBindings = true; continue;
1145
+ }
1146
+ rest.push(a);
1147
+ }
1148
+ const msg = rest.length ? util.format(...rest) : '';
1149
+ return hasBindings ? child[level](bindings, msg) : child[level](msg);
1115
1150
  };
1151
+ return { info: emit('info'), warn: emit('warn'), error: emit('error'), debug: emit('debug') };
1116
1152
  }
1117
1153
 
1118
1154
  module.exports = {
package/tests/logger.mjs CHANGED
@@ -87,3 +87,70 @@ test('requestIdMiddleware adopts a valid inbound id and echoes it; mints otherwi
87
87
  const huge = mk({ 'x-request-id': 'x'.repeat(500) });
88
88
  assert.match(huge.req.reqId, /^[0-9a-f]{16}$/, 'over-long inbound id is not adopted');
89
89
  });
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // task 1003208 (goal 1000079) — the DOORWAY logger, which is what server paths
93
+ // actually call.
94
+ //
95
+ // api.logger() used to be `console.log(tag, ...args)`. Every module that had
96
+ // "adopted the logger" was therefore still emitting unstructured text with no
97
+ // correlation and no redaction, so the tests above proved a property of a logger
98
+ // almost nothing in modules/ was using. These run the REAL doorway in a
99
+ // subprocess — pino writes through sonic-boom straight to the fd, so capturing
100
+ // the singleton in-process is not possible, and a re-built copy would prove
101
+ // something about the copy.
102
+ // ---------------------------------------------------------------------------
103
+ const { spawnSync } = require('node:child_process');
104
+
105
+ function runDoorway(body) {
106
+ const r = spawnSync(process.execPath, ['-e', `
107
+ const api = require(${JSON.stringify(path.join(ROOT, 'src', 'module-api.js'))});
108
+ const log = api.logger('probe');
109
+ ${body}
110
+ `], { encoding: 'utf8' });
111
+ assert.equal(r.status, 0, `doorway probe exited ${r.status}: ${r.stderr}`);
112
+ return r.stdout.split('\n').filter((l) => l.trim().startsWith('{')).map((l) => JSON.parse(l));
113
+ }
114
+
115
+ test('api.logger emits STRUCTURED json, not console text (the 1003208 regression)', () => {
116
+ const [line] = runDoorway(`log.info('mounted');`);
117
+ assert.equal(line.msg, 'mounted');
118
+ assert.equal(line.module, 'probe', 'the namespace is a field, not a "[tag]" prefix');
119
+ assert.equal(typeof line.level, 'number');
120
+ assert.ok(line.time, 'carries a timestamp');
121
+ });
122
+
123
+ test('a secret handed to api.logger console-style is redacted in the emitted line', () => {
124
+ const [line] = runDoorway(`log.info('storing credentials', { token: 'PLANTED-SECRET-VALUE', ok: true });`);
125
+ assert.equal(line.token, '[Redacted]', 'the secret-bearing FIELD is censored');
126
+ assert.equal(line.ok, true, 'the neighbouring field survives');
127
+ assert.equal(line.msg, 'storing credentials');
128
+ assert.ok(!JSON.stringify(line).includes('PLANTED-SECRET-VALUE'),
129
+ 'the planted secret appears nowhere in the record');
130
+ });
131
+
132
+ test('object args MERGE rather than being formatted into the message', () => {
133
+ // This is the property redaction depends on: pino censors by field path, so an
134
+ // object flattened into the message string would emit the secret verbatim.
135
+ const [line] = runDoorway(`log.warn('ctx', { password: 'hunter2' }, 'and more');`);
136
+ assert.equal(line.password, '[Redacted]');
137
+ assert.equal(line.msg, 'ctx and more', 'non-object args still format exactly as console did');
138
+ });
139
+
140
+ test('an Error arg is serialised, not stringified into the message', () => {
141
+ const [line] = runDoorway(`log.error('failed', new Error('boom'));`);
142
+ assert.equal(line.msg, 'failed');
143
+ assert.equal(line.err.type, 'Error');
144
+ assert.equal(line.err.message, 'boom');
145
+ assert.ok(line.err.stack, 'the stack is kept as a field');
146
+ });
147
+
148
+ test('a secret INTERPOLATED into the message is NOT redacted — the honest boundary', () => {
149
+ // Documented rather than pretended: pino redacts by field path, so a secret
150
+ // pasted into free text is the scrubber's and the prevent hook's job. Asserting
151
+ // it here stops anyone reading the tests above and concluding the logger alone
152
+ // makes a leak impossible.
153
+ const [line] = runDoorway(`log.info('token=' + 'PLANTED-SECRET-VALUE');`);
154
+ assert.ok(line.msg.includes('PLANTED-SECRET-VALUE'),
155
+ 'interpolated secrets survive — redaction is field-path based, by design');
156
+ });