@bongos/core 1.21.69 → 1.21.71

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 (68) hide show
  1. package/.bongos-core.json +119 -64
  2. package/.claude/skills/bongos-feedback/SKILL.md +10 -46
  3. package/.claude/skills/goal-create/SKILL.md +21 -3
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +8 -2
  6. package/clients/bongos-client/index.cjs +8 -2
  7. package/clients/bongos-client/index.d.ts +15 -6
  8. package/clients/bongos-client/index.mjs +8 -2
  9. package/docs/adr/0368-red-and-blue-teams-practice-on-a-range-never-on-live.md +99 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +196 -19
  12. package/docs/api-reference.md +9 -6
  13. package/docs/copy-inventory.md +25 -21
  14. package/docs/copy-registry.json +76 -30
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/page-readings.json +8 -8
  18. package/docs/recipes/core-release-pipeline.md +6 -0
  19. package/docs/recipes/upgrading-the-core.md +2 -0
  20. package/modules/hall-ui/public/settings-software-update.js +77 -2
  21. package/modules/hall-ui/public/settings.html +1 -1
  22. package/modules/lifecycle/db-tasks.js +12 -0
  23. package/modules/lifecycle/goal-spec-task.js +129 -0
  24. package/modules/lifecycle/routes/goals.js +7 -9
  25. package/modules/lifecycle/routes/task-write-routes.js +11 -1
  26. package/modules/lifecycle/upkeep-home.js +55 -0
  27. package/modules/platform-identity/instance-feedback.js +108 -7
  28. package/modules/platform-identity/routes/sso.js +71 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +42 -0
  32. package/scripts/gds/claude-materialize.js +125 -31
  33. package/scripts/gds/claude-md-template.js +47 -10
  34. package/scripts/gds/cli-lib.js +4 -4
  35. package/scripts/gds/feedback-send.js +146 -73
  36. package/scripts/gds/paste-token.js +1 -1
  37. package/scripts/gds/provision-net.js +1 -8
  38. package/scripts/gds/provision-units.js +28 -2
  39. package/scripts/gds/reauth.js +1 -1
  40. package/scripts/gds/run-unit-tests.js +3 -0
  41. package/scripts/gds/setup.js +1 -1
  42. package/scripts/gds/start.js +4 -4
  43. package/scripts/gds/surface.js +15 -3
  44. package/scripts/gds/task.js +32 -5
  45. package/scripts/gds/upgrade-operator.js +73 -0
  46. package/scripts/gds/upgrade.js +7 -2
  47. package/scripts/hall-preview/server.js +3 -0
  48. package/src/bongos/platform-visibility-gate.js +3 -0
  49. package/src/bongos/routes/core-update.js +98 -4
  50. package/src/bongos/routes/feedback.js +100 -76
  51. package/src/bongos/upgrade-request.js +158 -0
  52. package/src/module-api.js +1 -1
  53. package/tests/claude_materialize.mjs +195 -18
  54. package/tests/cli_allowlist_permissions.mjs +6 -2
  55. package/tests/cli_surface.mjs +2 -2
  56. package/tests/fixtures/page-ledger.mjs +5 -0
  57. package/tests/goal_admission_override.mjs +1 -1
  58. package/tests/goal_category.mjs +1 -1
  59. package/tests/goal_create_spec_entry.mjs +61 -0
  60. package/tests/goal_spec_task.mjs +197 -0
  61. package/tests/goal_spec_task_db.mjs +95 -0
  62. package/tests/instance_feedback.mjs +267 -68
  63. package/tests/provision_settings_apply.mjs +25 -1
  64. package/tests/software_update.mjs +18 -1
  65. package/tests/start_env_guard.mjs +2 -2
  66. package/tests/upgrade_operator.mjs +105 -0
  67. package/tests/upgrade_request.mjs +348 -0
  68. package/tests/upkeep_home.mjs +156 -0
@@ -1,110 +1,134 @@
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']);
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
+ };
78
+ }
39
79
 
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 };
80
+ // The process's one receipt store. Shared, because a receipt is redeemed at the public
81
+ // route below whoever issued it: the CLI's POST /feedback/receipt, or this project's own
82
+ // "Ask for this upgrade" (src/bongos/upgrade-request.js, task 1004595).
83
+ let sharedStore = null;
84
+ function defaultReceiptStore() {
85
+ if (!sharedStore) sharedStore = makeReceiptStore();
86
+ return sharedStore;
57
87
  }
58
88
 
59
- function buildFeedbackRouter({ idpConfig = safeIdpConfig, fetchImpl = fetch } = {}) {
89
+ function buildFeedbackRouter({ store = defaultReceiptStore(), hub = feedbackHub } = {}) {
60
90
  const router = express.Router();
61
91
 
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.
92
+ // rank: any signed-in builder — a receipt only says "this builder sent this
93
+ // text"; it grants nothing here, and the hub files every report behind a nod.
64
94
  router.post(
65
- '/feedback',
95
+ '/feedback/receipt',
66
96
  auth.requireBuilder,
67
- asyncHandler('POST /feedback', async (req, res) => {
97
+ asyncHandler('POST /feedback/receipt', async (req, res) => {
68
98
  if (validateOrRespond(req, res, {
69
- message: { required: true, type: 'string', maxLength: MESSAGE_MAX },
99
+ sha256: { required: true, type: 'string', maxLength: 64 },
70
100
  kind: { type: 'string', enum: ['bug', 'idea'] },
71
- context: {},
72
101
  })) 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 = {
102
+ const b = req.body;
103
+ if (!/^[a-f0-9]{64}$/.test(b.sha256)) return res.fail('validation_failed', 400, [{ field: 'sha256', reason: 'not_a_sha256' }]);
104
+ const receipt = store.issue({
105
+ github_id: String(req.builder.github_id),
106
+ github_login: req.builder.github_login || null,
107
+ sha256: b.sha256,
108
+ kind: b.kind || 'bug',
83
109
  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 }),
110
+ });
111
+ if (!receipt) return res.fail('rate_limited', 429, { retry_after_seconds: Math.ceil(RECEIPT_TTL_MS / 1000) });
112
+ res.status(201).json({ ok: true, receipt, hub: hub() });
113
+ }, { errorCode: 'feedback_receipt_failed', message: false }),
105
114
  );
106
115
 
116
+ // rank: public — the hub redeems a receipt with no session of its own. The id
117
+ // is 144 random bits, known only to the CLI that asked for it and the hub it
118
+ // sent it to, and it works once.
119
+ router.get('/feedback/receipt/:id', (req, res) => {
120
+ const id = String(req.params.id || '');
121
+ const receipt = /^[A-Za-z0-9_-]{16,64}$/.test(id) ? store.redeem(id) : null;
122
+ res.set('Cache-Control', 'no-store');
123
+ if (!receipt) return res.fail('receipt_unknown', 404);
124
+ res.json({ ok: true, ...receipt });
125
+ });
126
+
107
127
  return router;
108
128
  }
109
129
 
110
130
  module.exports = buildFeedbackRouter;
131
+ module.exports.makeReceiptStore = makeReceiptStore;
132
+ module.exports.defaultReceiptStore = defaultReceiptStore;
133
+ module.exports.feedbackHub = feedbackHub;
134
+ module.exports.DEFAULT_FEEDBACK_HUB = DEFAULT_FEEDBACK_HUB;
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/upgrade-request.js — "Ask for this upgrade" on Settings → Software update
4
+ // (task 1004595).
5
+ //
6
+ // WHY. A project that Cloud Bongos hosts is upgraded by the people who run its server,
7
+ // not by its owner. Its Settings page said "Update ready" and then nothing: no button,
8
+ // no next step (Will, mercury, 2026-10-06, 46 versions behind with nobody told). This
9
+ // file gives that owner one press that reaches the operator, and remembers that it was
10
+ // pressed so the page can say the request is pending.
11
+ //
12
+ // THE CHANNEL IS ONE THAT ALREADY EXISTS: the direct feedback door (task 1004591). The
13
+ // project issues a one-time receipt for the signed-in builder in its OWN receipt store
14
+ // (src/bongos/routes/feedback.js), then posts the request straight to the hub's
15
+ // POST /api/bongos/sso/feedback/direct. The hub redeems the receipt at this project's
16
+ // public URL, so the request lands in the maintainers' backlog attributed to the person
17
+ // who really pressed the button, with no new credential and no new hub route.
18
+ //
19
+ // ONE REQUEST PER TARGET VERSION. Asking again for a version already asked for is
20
+ // answered from the stored row and sends nothing. A different version may be asked for
21
+ // once the cooldown has passed, so a project that falls further behind can say so,
22
+ // without a press per patch release flooding the backlog.
23
+
24
+ const crypto = require('node:crypto');
25
+
26
+ const SETTING_KEY = 'software_update.request';
27
+ const COOLDOWN_MS = 24 * 60 * 60 * 1000;
28
+ const HUB_TIMEOUT_MS = 15000;
29
+
30
+ const VERSION_RE = /^\d{1,4}\.\d{1,5}\.\d{1,6}$/;
31
+ const LOGIN_RE = /^[A-Za-z0-9-]{1,39}$/;
32
+
33
+ // The host this hall answers on, as the hub will need it to redeem the receipt: a bare
34
+ // public DNS name. Anything else (an IP, a port, localhost, a dev name) cannot be
35
+ // reached by the hub, so it is not askable. Mirrors the hub's own projectOrigin check.
36
+ function projectHost(raw) {
37
+ const host = String(raw || '').trim().toLowerCase();
38
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) return null;
39
+ if (/^\d+(\.\d+)+$/.test(host)) return null;
40
+ if (/(^|\.)(localhost|local|internal|test|invalid)$/.test(host)) return null;
41
+ return host;
42
+ }
43
+
44
+ function hubHostOf(hub) {
45
+ try { return new URL(String(hub)).host.toLowerCase(); } catch { return null; }
46
+ }
47
+
48
+ // Is this a project the hub's operator runs? Its door is the hub's, or it answers on a
49
+ // name under the hub's own domain (the same rule the hub uses to call a sender "verified").
50
+ function isOperatorRun({ doorKind, host, hub }) {
51
+ if (doorKind === 'local' || !hub) return false;
52
+ if (doorKind === 'hub') return true;
53
+ const h = hubHostOf(hub);
54
+ return !!(h && host && (host === h || host.endsWith(`.${h}`)));
55
+ }
56
+
57
+ // The stored request, or null. A row this file did not write reads as no request.
58
+ function parseStored(row) {
59
+ if (!row || typeof row.value !== 'string') return null;
60
+ let v;
61
+ try { v = JSON.parse(row.value); } catch { return null; }
62
+ if (!v || !VERSION_RE.test(String(v.version || ''))) return null;
63
+ const at = Date.parse(v.requested_at);
64
+ if (!Number.isFinite(at)) return null;
65
+ return {
66
+ version: String(v.version),
67
+ requested_at: new Date(at).toISOString(),
68
+ by: typeof v.by === 'string' && LOGIN_RE.test(v.by) ? v.by : null,
69
+ };
70
+ }
71
+
72
+ // What the page needs: may it offer the button, and what was asked before.
73
+ // `ask_again_after` is when a request for a DIFFERENT version would be taken.
74
+ function askState({ offered, host, doorKind, hub, stored }) {
75
+ const available = !!offered && isOperatorRun({ doorKind, host, hub }) && !!projectHost(host);
76
+ const ask = { available, hub_host: hub ? hubHostOf(hub) : null, requested: stored || null, ask_again_after: null };
77
+ if (stored) ask.ask_again_after = new Date(Date.parse(stored.requested_at) + COOLDOWN_MS).toISOString();
78
+ return ask;
79
+ }
80
+
81
+ // The text the operator reads. Its first line becomes the hub task's title.
82
+ function requestMessage({ host, running, wanted, login }) {
83
+ const who = login && LOGIN_RE.test(login) ? `@${login}` : 'its owner';
84
+ return [
85
+ `Upgrade request: ${host} asks for core ${wanted} (it runs ${running || 'an unknown version'})`,
86
+ '',
87
+ `${who} pressed "Ask for this upgrade" in Settings, Software update on ${host}.`,
88
+ 'This project cannot upgrade itself: the people who run its server do it.',
89
+ `Move its core to ${wanted} or newer, or tell them when it will be.`,
90
+ ].join('\n');
91
+ }
92
+
93
+ /**
94
+ * Send one upgrade request, or answer from the stored one. Never throws for an
95
+ * expected refusal; returns { status, body } for the route to send.
96
+ *
97
+ * deps: { hub, issueReceipt(receipt) → id|null, fetchImpl, getSetting, setSetting, now }
98
+ */
99
+ async function requestUpgrade({ host, running, wanted, builder, doorKind }, deps) {
100
+ const now = deps.now || Date.now;
101
+ const hub = deps.hub;
102
+ const target = projectHost(host);
103
+ if (!wanted || !VERSION_RE.test(String(wanted))) return { status: 409, body: { error: 'nothing_to_ask', message: 'This project is up to date.' } };
104
+ if (!target || !isOperatorRun({ doorKind, host: target, hub })) {
105
+ return { status: 409, body: { error: 'not_operator_run', message: 'This project is not upgraded by an operator, so there is no one to ask.' } };
106
+ }
107
+
108
+ const stored = parseStored(await deps.getSetting(SETTING_KEY));
109
+ if (stored && stored.version === wanted) return { status: 200, body: { ok: true, already: true, request: stored } };
110
+ if (stored && now() < Date.parse(stored.requested_at) + COOLDOWN_MS) {
111
+ const retry = Math.ceil((Date.parse(stored.requested_at) + COOLDOWN_MS - now()) / 1000);
112
+ return { status: 429, body: { error: 'asked_recently', message: 'This project already asked for an upgrade today. Ask again tomorrow.', retry_after_seconds: retry, request: stored } };
113
+ }
114
+
115
+ const login = builder && typeof builder.github_login === 'string' && LOGIN_RE.test(builder.github_login) ? builder.github_login : null;
116
+ const message = requestMessage({ host: target, running, wanted, login });
117
+ const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
118
+ const receipt = deps.issueReceipt({
119
+ github_id: String(builder && builder.github_id),
120
+ github_login: login,
121
+ sha256,
122
+ kind: 'bug',
123
+ core_version: running || null,
124
+ });
125
+ if (!receipt) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in ten minutes.', retry_after_seconds: 600 } };
126
+
127
+ let res;
128
+ try {
129
+ res = await (deps.fetchImpl || fetch)(`${String(hub).replace(/\/+$/, '')}/api/bongos/sso/feedback/direct`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
132
+ body: JSON.stringify({ origin: `https://${target}`, receipt, message, kind: 'bug', context: {} }),
133
+ redirect: 'error',
134
+ signal: AbortSignal.timeout(HUB_TIMEOUT_MS),
135
+ });
136
+ } catch {
137
+ return { status: 502, body: { error: 'operator_unreachable', message: 'Could not reach the people who run this project. Try again in a few minutes.' } };
138
+ }
139
+ if (res.status === 429) return { status: 429, body: { error: 'rate_limited', message: 'Too many requests just now. Try again in an hour.', retry_after_seconds: 3600 } };
140
+ if (res.status !== 202) {
141
+ return { status: 502, body: { error: 'operator_unreachable', message: `The request was not taken (HTTP ${res.status}). Try again later.` } };
142
+ }
143
+
144
+ const request = { version: String(wanted), requested_at: new Date(now()).toISOString(), by: login };
145
+ await deps.setSetting(SETTING_KEY, JSON.stringify(request), builder && builder.id != null ? builder.id : null);
146
+ return { status: 201, body: { ok: true, already: false, request } };
147
+ }
148
+
149
+ module.exports = {
150
+ SETTING_KEY,
151
+ COOLDOWN_MS,
152
+ projectHost,
153
+ isOperatorRun,
154
+ parseStored,
155
+ askState,
156
+ requestMessage,
157
+ requestUpgrade,
158
+ };
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.71'; // 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');