@bongos/core 1.21.78 → 1.21.80

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 (77) hide show
  1. package/.bongos-core.json +108 -78
  2. package/.claude/skills/goal-create/SKILL.md +2 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +8 -0
  5. package/clients/bongos-client/index.cjs +8 -0
  6. package/clients/bongos-client/index.d.ts +13 -0
  7. package/clients/bongos-client/index.mjs +8 -0
  8. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +1 -0
  9. package/docs/adr/0370-a-repo-we-create-is-private.md +29 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +285 -3
  12. package/docs/api-reference.md +6 -2
  13. package/docs/copy-inventory.md +40 -33
  14. package/docs/copy-registry.json +125 -61
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +4 -2
  17. package/docs/page-readings.json +216 -207
  18. package/migrations/core_273_module_install_reports.sql +66 -0
  19. package/modules/hall-ui/public/atlas.html +1 -1
  20. package/modules/hall-ui/public/blockers.html +1 -1
  21. package/modules/hall-ui/public/board-room.html +1 -1
  22. package/modules/hall-ui/public/city.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/commands.html +1 -1
  25. package/modules/hall-ui/public/copy-desk.html +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/idea.html +1 -1
  35. package/modules/hall-ui/public/ideas.html +1 -1
  36. package/modules/hall-ui/public/index.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/primer.html +1 -1
  39. package/modules/hall-ui/public/profile.html +1 -1
  40. package/modules/hall-ui/public/project-settings.html +1 -1
  41. package/modules/hall-ui/public/ranks.html +1 -1
  42. package/modules/hall-ui/public/roadmap.html +1 -1
  43. package/modules/hall-ui/public/roster.html +1 -1
  44. package/modules/hall-ui/public/sessions.html +1 -1
  45. package/modules/hall-ui/public/settings-usage-reports.js +96 -0
  46. package/modules/hall-ui/public/settings.html +19 -1
  47. package/modules/hall-ui/public/settings.states.json +1 -0
  48. package/modules/hall-ui/public/shell.js +61 -29
  49. package/modules/hall-ui/public/street.html +1 -1
  50. package/modules/hall-ui/public/studio.html +1 -1
  51. package/modules/hall-ui/public/task.html +1 -1
  52. package/modules/hall-ui/public/thinking.html +1 -1
  53. package/modules/hall-ui/public/tweak-editor-lib.js +22 -3
  54. package/modules/hall-ui/public/tweak-editor.html +1 -1
  55. package/modules/hall-ui/public/watch.html +1 -1
  56. package/modules/hall-ui/public/work.html +1 -1
  57. package/modules/hall-ui/records/tweak-editor.md +1 -1
  58. package/modules/public-landing/public/projects.html +5 -5
  59. package/package-lock.json +2 -2
  60. package/package.json +1 -1
  61. package/release-notes.json +28 -0
  62. package/scripts/gds/module-assess-installs.js +143 -0
  63. package/scripts/gds/module.js +29 -5
  64. package/scripts/hall-preview/live-identity.js +1 -0
  65. package/src/bongos/github-repos.js +7 -6
  66. package/src/bongos/module-usage-reports.js +112 -0
  67. package/src/bongos/routes/modules.js +96 -0
  68. package/src/module-api.js +1 -1
  69. package/tests/github_repos_helpers.mjs +5 -5
  70. package/tests/goal_map_page.mjs +1 -1
  71. package/tests/hall_commands.mjs +1 -1
  72. package/tests/hall_nav.mjs +42 -0
  73. package/tests/hall_tweak_editor.mjs +13 -1
  74. package/tests/module_assess_installs.mjs +272 -0
  75. package/tests/module_install.mjs +3 -0
  76. package/tests/module_update.mjs +3 -0
  77. package/tests/nav_permission_atoms.mjs +1 -1
@@ -1138,14 +1138,34 @@ async function fetchVerified(key, { wanted, call, core, log, errlog, verb, shoul
1138
1138
 
1139
1139
  const artifact = require('./module-artifact');
1140
1140
  const verified = await artifact.verifyModuleArtifact(dl.buf, { key, includeFiles: true });
1141
- if (!verified.ok) return fail(1, `REFUSING: the downloaded tarball failed verification (${verified.code}): ${verified.message}`);
1141
+ if (!verified.ok) {
1142
+ await reportInstall(call, key, described.version, 'verify_failed');
1143
+ return fail(1, `REFUSING: the downloaded tarball failed verification (${verified.code}): ${verified.message}`);
1144
+ }
1142
1145
  if (verified.version !== described.version || verified.tarballSha256 !== described.tarball_sha256 || verified.treeSha256 !== described.tree_sha256) {
1146
+ await reportInstall(call, key, described.version, 'hash_mismatch');
1143
1147
  return fail(1, `REFUSING: the download is not the ${described.version} the store described (version or hash differs).`);
1144
1148
  }
1145
1149
  log(` ✓ verified ${verified.files.length} file(s), ${verified.bytes} bytes — tree ${verified.treeSha256.slice(0, 12)}…`);
1146
1150
  return { ok: true, described, verified };
1147
1151
  }
1148
1152
 
1153
+ // Tell the store whether an install or update of this exact version worked (task
1154
+ // 1003793): it feeds the module's Install score, the part an author cannot fake.
1155
+ // Counts only — yes, or a short code saying where it failed; never a message, a
1156
+ // path or anything about this machine (owner's privacy posture, 2026-10-01). The
1157
+ // store drops it if the caller switched reporting off in Settings, so there is no
1158
+ // client-side switch to keep in step. Best-effort and silent: a report that does
1159
+ // not arrive changes nothing about the install. Only failures that are the
1160
+ // module's own count: a refused or unreachable store, or a core the module does
1161
+ // not support, says nothing about the module and is not reported.
1162
+ async function reportInstall(call, key, version, errorCode = null) {
1163
+ const body = errorCode ? { ok: false, error_code: errorCode } : { ok: true };
1164
+ try {
1165
+ await call('POST', `/store/modules/${encodeURIComponent(key)}/versions/${encodeURIComponent(version)}/install-report`, { json: body });
1166
+ } catch { /* best-effort */ }
1167
+ }
1168
+
1149
1169
  // Tell the store which exact version was placed. A failure here never undoes the
1150
1170
  // placement: the files are good, and the next install or update records it.
1151
1171
  async function recordPlaced(key, version, { call, errlog, verb }) {
@@ -1190,10 +1210,12 @@ async function cmdInstall(args, {
1190
1210
  if (!got.ok) return got.code;
1191
1211
 
1192
1212
  try { placeModule(got.verified.files, target); }
1193
- catch (e) { errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
1213
+ catch (e) { await reportInstall(call, key, got.described.version, 'place_failed'); errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
1194
1214
  log(` ✓ placed at ${target}`);
1195
1215
 
1196
- if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
1216
+ const recorded = await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' });
1217
+ await reportInstall(call, key, got.described.version);
1218
+ if (!recorded) return 1;
1197
1219
  log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
1198
1220
  const howto = howtoLink(key, got.described.version, got.verified.files, call.apiBase);
1199
1221
  if (howto) log(` How to use it: ${howto}`);
@@ -1276,10 +1298,12 @@ async function cmdUpdate(args, {
1276
1298
  if (!got.ok) return got.code;
1277
1299
 
1278
1300
  try { replaceModule(got.verified.files, target); }
1279
- catch (e) { errlog(`bongos module update: could not replace the module, ${installed} is still in place — ${e.message}`); return 1; }
1301
+ catch (e) { await reportInstall(call, key, got.described.version, 'place_failed'); errlog(`bongos module update: could not replace the module, ${installed} is still in place — ${e.message}`); return 1; }
1280
1302
  log(` ✓ replaced ${installed} with ${got.described.version} at ${target}`);
1281
1303
 
1282
- if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'update' }))) return 1;
1304
+ const recorded = await recordPlaced(key, got.described.version, { call, errlog, verb: 'update' });
1305
+ await reportInstall(call, key, got.described.version);
1306
+ if (!recorded) return 1;
1283
1307
  log(`\n✓ Updated "${key}" ${installed} → ${got.described.version}. Its on/off switch is unchanged.`);
1284
1308
  return 0;
1285
1309
  }
@@ -34,6 +34,7 @@ const ME_SCOPED_PATHS = [
34
34
  'me/sound-prefs',
35
35
  'me/event-sound-prefs',
36
36
  'me/art-key/own',
37
+ 'store/usage-reporting',
37
38
  'builders/:id/profile',
38
39
  'builders/:id/active-claims',
39
40
  'builders/:id/activity?section=tasks',
@@ -75,10 +75,11 @@ async function listUserRepos(token, { q = '', fetchImpl = fetch } = {}) {
75
75
  // no README/first commit (exactly the "leave it completely empty" the manual step
76
76
  // demanded).
77
77
  //
78
- // private:false is now a PRODUCT choice, not a credential limit: since ADR 0155 the
79
- // token holds `repo` and COULD create a private repo, but a Cloud Bongos instance is
80
- // open-source / capture-resistant by design, so a repo WE create defaults to public.
81
- // A user who wants a private instance adopts a private repo they made themselves.
78
+ // private:true — a repo WE create is PRIVATE (ADR 0370, superseding ADR 0155 §3's
79
+ // public default): a new owner's code must never be world-readable from the first
80
+ // push without them choosing that. The standup already handles a private target —
81
+ // ensurePrivateRepoAccess mints the project's own read-only deploy key before the
82
+ // first pull (ADR 0176) — and the owner can make it public later from the manage page.
82
83
  // Deliberately not a parameter — nothing should be able to flip this implicitly.
83
84
  //
84
85
  // Returns { full_name, html_url }. A 422 (name already exists / invalid) becomes a
@@ -87,7 +88,7 @@ async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
87
88
  const res = await fetchImpl(`${GH_API}/user/repos`, {
88
89
  method: 'POST',
89
90
  headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
90
- body: JSON.stringify({ name, private: false, auto_init: false }),
91
+ body: JSON.stringify({ name, private: true, auto_init: false }),
91
92
  });
92
93
  if (res.status === 201) {
93
94
  const r = await res.json();
@@ -131,7 +132,7 @@ async function getUserRepo(token, { owner, name, fetchImpl = fetch } = {}) {
131
132
 
132
133
  // The visibility PATCH both directions share. Deliberately NOT exported: callers get
133
134
  // one named function per direction, so the name stays the contract and no caller can
134
- // pass a direction it did not mean (the createUserRepo private:false precedent).
135
+ // pass a direction it did not mean (the createUserRepo fixed-visibility precedent).
135
136
  async function setRepoPrivate(token, { owner, name, fetchImpl = fetch }, isPrivate) {
136
137
  const res = await fetchImpl(`${GH_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, {
137
138
  method: 'PATCH',
@@ -0,0 +1,112 @@
1
+ // src/bongos/module-usage-reports.js — take in the counts real installs report
2
+ // about a store module version: did the install work, and how many runtime
3
+ // errors it threw on a day (task 1003793, ADR 0343 D1; tables core_273).
4
+ //
5
+ // WHY a separate intake. Install and Reliability are the parts of a module's
6
+ // score an author cannot fake, because they come from other people's installs.
7
+ // That makes them telemetry about someone else's setup, so the owner set the
8
+ // privacy posture up front (Will, 2026-10-01): COUNTS ONLY, ON BY DEFAULT, WITH
9
+ // AN OPT-OUT. This file is where that posture is enforced, at the server, so it
10
+ // holds whatever client sends the report:
11
+ // - a report is a yes/no plus a short error CODE, or a whole-number daily
12
+ // count — there is no field for error text, paths or user data;
13
+ // - a holder that has switched reporting off is dropped at intake, before
14
+ // anything is written.
15
+ // Scoring the reports into signal rows is module-assess-installs.js's job.
16
+ //
17
+ // Every function takes { db } (a pg Pool or anything with query()) so tests run
18
+ // against a fake client, the way module-entitlements.js does.
19
+
20
+ const ERROR_CODE_RE = /^[a-z][a-z0-9_]{0,39}$/; // same rule as core_273's CHECK
21
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
22
+ const MAX_DAILY_ERRORS = 1_000_000;
23
+ // How far back a daily count may be sent. Matches the window Reliability is
24
+ // scored over (module-assess-installs.js), so nothing is stored that never counts.
25
+ const ERROR_DAY_WINDOW = 30;
26
+
27
+ function poolOf(db) { return db || require('./pool').pool; }
28
+
29
+ // Does this holder have usage reporting switched off? No row = on (the default).
30
+ async function isOptedOut({ holderKind, holderRef }, { db } = {}) {
31
+ const { rows } = await poolOf(db).query(
32
+ 'SELECT 1 FROM module_usage_report_optouts WHERE holder_kind = $1 AND holder_ref = $2',
33
+ [holderKind, holderRef]);
34
+ return rows.length > 0;
35
+ }
36
+
37
+ // Switch a holder's reporting on or off. Idempotent either way.
38
+ async function setReporting({ holderKind, holderRef, enabled }, { db } = {}) {
39
+ const pool = poolOf(db);
40
+ if (enabled) {
41
+ await pool.query('DELETE FROM module_usage_report_optouts WHERE holder_kind = $1 AND holder_ref = $2',
42
+ [holderKind, holderRef]);
43
+ } else {
44
+ await pool.query(
45
+ `INSERT INTO module_usage_report_optouts (holder_kind, holder_ref) VALUES ($1, $2)
46
+ ON CONFLICT (holder_kind, holder_ref) DO NOTHING`, [holderKind, holderRef]);
47
+ }
48
+ return { enabled: !!enabled };
49
+ }
50
+
51
+ // Check a report's shape. Returns null when fine, else { code, message } — the
52
+ // route turns that into a 400. Pure.
53
+ function installReportError({ ok, errorCode }) {
54
+ if (typeof ok !== 'boolean') return { code: 'bad_report', message: 'ok is true or false.' };
55
+ if (ok && errorCode != null) return { code: 'bad_report', message: 'A working install carries no error code.' };
56
+ if (!ok && !ERROR_CODE_RE.test(String(errorCode || ''))) {
57
+ return { code: 'bad_error_code', message: 'A failed install carries a short lowercase code (letters, digits, _), never a message.' };
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // Check a daily error count. `today` is a YYYY-MM-DD string (UTC) so tests can pin it. Pure.
63
+ function errorCountError({ day, count }, { today = new Date().toISOString().slice(0, 10) } = {}) {
64
+ if (!Number.isInteger(count) || count < 0 || count > MAX_DAILY_ERRORS) {
65
+ return { code: 'bad_count', message: `count is a whole number from 0 to ${MAX_DAILY_ERRORS}.` };
66
+ }
67
+ if (!DAY_RE.test(String(day || '')) || Number.isNaN(Date.parse(`${day}T00:00:00Z`))) {
68
+ return { code: 'bad_day', message: 'day is a date, YYYY-MM-DD.' };
69
+ }
70
+ const ageDays = (Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / 86_400_000;
71
+ if (ageDays < 0) return { code: 'bad_day', message: 'day cannot be in the future.' };
72
+ if (ageDays > ERROR_DAY_WINDOW) return { code: 'bad_day', message: `day must be within the last ${ERROR_DAY_WINDOW} days.` };
73
+ return null;
74
+ }
75
+
76
+ // Record a holder's latest install or update attempt of a version — it REPLACES
77
+ // their previous one, so retries never grow the table or outweigh other holders.
78
+ // Resolves { recorded: true } or { recorded: false, reason: 'opted_out' }.
79
+ // Validate with installReportError first.
80
+ async function recordInstallReport({ versionId, holderKind, holderRef, ok, errorCode = null }, { db } = {}) {
81
+ const pool = poolOf(db);
82
+ if (await isOptedOut({ holderKind, holderRef }, { db: pool })) return { recorded: false, reason: 'opted_out' };
83
+ await pool.query(
84
+ `INSERT INTO module_install_reports (version_id, holder_kind, holder_ref, ok, error_code)
85
+ VALUES ($1, $2, $3, $4, $5)
86
+ ON CONFLICT (version_id, holder_kind, holder_ref)
87
+ DO UPDATE SET ok = EXCLUDED.ok, error_code = EXCLUDED.error_code, reported_at = now()`,
88
+ [versionId, holderKind, holderRef, ok, ok ? null : errorCode]);
89
+ return { recorded: true };
90
+ }
91
+
92
+ // Record a version's runtime error count for one holder on one day. A second
93
+ // report for the same day REPLACES the count (the sender counts the whole day),
94
+ // so a retried send never doubles it. Validate with errorCountError first.
95
+ async function recordErrorCount({ versionId, holderKind, holderRef, day, count }, { db } = {}) {
96
+ const pool = poolOf(db);
97
+ if (await isOptedOut({ holderKind, holderRef }, { db: pool })) return { recorded: false, reason: 'opted_out' };
98
+ await pool.query(
99
+ `INSERT INTO module_runtime_error_counts (version_id, holder_kind, holder_ref, day, error_count)
100
+ VALUES ($1, $2, $3, $4, $5)
101
+ ON CONFLICT (version_id, holder_kind, holder_ref, day)
102
+ DO UPDATE SET error_count = EXCLUDED.error_count, reported_at = now()`,
103
+ [versionId, holderKind, holderRef, day, count]);
104
+ return { recorded: true };
105
+ }
106
+
107
+ module.exports = {
108
+ ERROR_CODE_RE, ERROR_DAY_WINDOW,
109
+ isOptedOut, setReporting,
110
+ installReportError, errorCountError,
111
+ recordInstallReport, recordErrorCount,
112
+ };
@@ -38,6 +38,13 @@
38
38
  // POST /store/modules/:key/delist
39
39
  // Metic+ takes a module out of the store; holders
40
40
  // keep it (task 1003797, ADR 0338 D2).
41
+ // POST /store/modules/:key/versions/:version/install-report
42
+ // POST /store/modules/:key/versions/:version/error-count
43
+ // GET|POST /store/usage-reporting
44
+ // counts real installs report — did an install work,
45
+ // runtime errors per day — scored into the Install and
46
+ // Reliability parts, and the caller's own switch to
47
+ // stop reporting (task 1003793, ADR 0343 D1).
41
48
  //
42
49
  // Read is open to any signed-in builder (same gate as the atlas/primer pages);
43
50
  // every write (enable/disable, submit) is metic+archon, same rank as
@@ -90,6 +97,10 @@ const {
90
97
  resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
91
98
  delistModule, delistedKeySet,
92
99
  } = require('../module-store');
100
+ const {
101
+ isOptedOut, setReporting, installReportError, errorCountError, recordInstallReport, recordErrorCount,
102
+ } = require('../module-usage-reports');
103
+ const { recordInstallSignals } = require('../../../scripts/gds/module-assess-installs');
93
104
  const { KEY_RE } = require('../../module-loader/manifest-schema');
94
105
  const { parseVersion } = require('../../module-loader/semver');
95
106
  const log = require('../logger').logger.child({ src: 'modules' });
@@ -726,6 +737,91 @@ module.exports = function buildModulesRouter() {
726
737
  res.json({ ok: true, entitlement: out.entitlement });
727
738
  }));
728
739
 
740
+ // ---- usage reports: the Install and Reliability parts (task 1003793, ADR 0343 D1) ----
741
+ //
742
+ // Real installs report counts back so a module is scored on what happens when
743
+ // other people run it. Owner's privacy posture (2026-10-01): counts only, on by
744
+ // default, with an opt-out — enforced in src/bongos/module-usage-reports.js, which
745
+ // drops a report from a holder who switched reporting off before anything is
746
+ // written. Own-scoped like /acquired: the holder is always the caller, never
747
+ // named in the request, so requireBuilder is the gate, and only a LIVE holder of
748
+ // the module may report on it. Each accepted report re-scores the two parts.
749
+ async function reportTarget(req, res) {
750
+ const { key, version } = req.params;
751
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
752
+ res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
753
+ return null;
754
+ }
755
+ const r = await resolveReadable(key, version);
756
+ if (!r.ok) { res.fail(r.code, { status: r.status, message: r.message }); return null; }
757
+ const holder = { holderKind: 'builder', holderRef: String(req.builder.id) };
758
+ const held = await checkEntitlement({ moduleKey: key, ...holder });
759
+ if (!held.entitled) {
760
+ res.fail('not_entitled', { status: 403, message: `You do not hold "${key}", so you cannot report on it.` });
761
+ return null;
762
+ }
763
+ return { versionId: r.version.id, ...holder };
764
+ }
765
+
766
+ // Re-score after an accepted report, once the response is sent so the reporter
767
+ // never waits on it. A scoring failure never loses the report; the next report
768
+ // re-scores from the same stored rows.
769
+ function rescore(versionId) {
770
+ setImmediate(() => {
771
+ recordInstallSignals(versionId).catch((e) => {
772
+ log.warn({ err: e.message, versionId }, 'install/reliability re-score failed; the report is kept');
773
+ });
774
+ });
775
+ }
776
+
777
+ router.post('/store/modules/:key/versions/:version/install-report', auth.requireBuilder,
778
+ asyncHandler('POST /store/modules/:key/versions/:version/install-report', async (req, res) => {
779
+ if (validateOrRespond(req, res, {
780
+ ok: { type: 'boolean', required: true },
781
+ error_code: { type: 'string', maxLength: 40 },
782
+ })) return;
783
+ const report = { ok: req.body.ok, errorCode: req.body.error_code == null ? null : req.body.error_code };
784
+ const bad = installReportError(report);
785
+ if (bad) return res.fail(bad.code, { status: 400, message: bad.message });
786
+ const t = await reportTarget(req, res);
787
+ if (!t) return;
788
+ const out = await recordInstallReport({ ...t, ...report });
789
+ res.json({ ok: true, recorded: out.recorded, ...(out.reason ? { reason: out.reason } : {}) });
790
+ if (out.recorded) rescore(t.versionId);
791
+ }));
792
+
793
+ router.post('/store/modules/:key/versions/:version/error-count', auth.requireBuilder,
794
+ asyncHandler('POST /store/modules/:key/versions/:version/error-count', async (req, res) => {
795
+ if (validateOrRespond(req, res, {
796
+ day: { type: 'string', required: true, maxLength: 10 },
797
+ count: { type: 'integer', required: true },
798
+ })) return;
799
+ const report = { day: req.body.day, count: req.body.count };
800
+ const bad = errorCountError(report);
801
+ if (bad) return res.fail(bad.code, { status: 400, message: bad.message });
802
+ const t = await reportTarget(req, res);
803
+ if (!t) return;
804
+ const out = await recordErrorCount({ ...t, ...report });
805
+ res.json({ ok: true, recorded: out.recorded, ...(out.reason ? { reason: out.reason } : {}) });
806
+ if (out.recorded) rescore(t.versionId);
807
+ }));
808
+
809
+ // The caller's own reporting switch (Settings → Profile and privacy). On unless
810
+ // they turned it off.
811
+ router.get('/store/usage-reporting', auth.requireBuilder,
812
+ asyncHandler('GET /store/usage-reporting', async (req, res) => {
813
+ const off = await isOptedOut({ holderKind: 'builder', holderRef: String(req.builder.id) });
814
+ res.set('Cache-Control', 'no-store');
815
+ res.json({ enabled: !off });
816
+ }));
817
+
818
+ router.post('/store/usage-reporting', auth.requireBuilder,
819
+ asyncHandler('POST /store/usage-reporting', async (req, res) => {
820
+ if (validateOrRespond(req, res, { enabled: { type: 'boolean', required: true } })) return;
821
+ const out = await setReporting({ holderKind: 'builder', holderRef: String(req.builder.id), enabled: req.body.enabled });
822
+ res.json({ ok: true, enabled: out.enabled });
823
+ }));
824
+
729
825
  return router;
730
826
  };
731
827
 
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.78'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.80'; // 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');
@@ -92,7 +92,7 @@ test('listUserRepos: 401 → typed github_unauthorized', async () => {
92
92
  );
93
93
  });
94
94
 
95
- test('createUserRepo: sends auto_init:false + private:false, returns full_name/html_url', async () => {
95
+ test('createUserRepo: sends auto_init:false + private:true, returns full_name/html_url', async () => {
96
96
  let sentBody = null;
97
97
  const fetchImpl = async (url, init) => {
98
98
  sentBody = JSON.parse(init.body);
@@ -100,10 +100,10 @@ test('createUserRepo: sends auto_init:false + private:false, returns full_name/h
100
100
  };
101
101
  const out = await gh.createUserRepo('tok', { name: 'mercury', fetchImpl });
102
102
  assert.equal(sentBody.auto_init, false, 'empty repo — no README/first commit');
103
- // Still public AFTER the ADR 0155 scope widen: `repo` COULD create a private repo,
104
- // but a repo Cloud Bongos creates stays public by product choice (open-source /
105
- // capture-resistant by design). Guards against the widen silently changing what we make.
106
- assert.equal(sentBody.private, false, 'a repo WE create is public by product choice');
103
+ // PRIVATE by default (ADR 0370, superseding ADR 0155 §3): a new owner's code is never
104
+ // world-readable from the first push unless they choose that. strictEqual on `true`, so
105
+ // a dropped field (GitHub's own default is public) reds here too.
106
+ assert.equal(sentBody.private, true, 'a repo WE create is private by default');
107
107
  assert.equal(sentBody.name, 'mercury');
108
108
  assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
109
109
  });
@@ -413,7 +413,7 @@ await test('a goal title never becomes markup — in the ledger, the panel or th
413
413
 
414
414
  await test('Sky sits directly under Goals in the nav, member-gated, at the URL Goals links to', () => {
415
415
  const shell = read('shell.js');
416
- const item = shell.indexOf("{ id: 'goal-map', label: 'Sky', icon: 'goals', href: '/goal-map', gate: 'member' }");
416
+ const item = shell.indexOf("{ id: 'goal-map', label: 'Sky', icon: 'sky', href: '/goal-map', gate: 'member' }");
417
417
  assert.ok(item > -1, 'the nav item, with the slot the map was denied (ADR 0227 §11)');
418
418
  const goals = shell.indexOf("{ id: 'goals', label: 'Goals'"); const work = shell.indexOf("{ id: 'work', label: 'Tasks'");
419
419
  assert.ok(goals > -1 && goals < item && item < work, 'under Goals, above Tasks');
@@ -156,7 +156,7 @@ test('the page is wired: nav entry in Docs, sign-in gated, both reads, no hand-t
156
156
  assert.match(js, /\/api\/bongos\/cli\/commands/);
157
157
  assert.ok(!/data-skill=|data-verb=|\/builder-ship|bongos claim/.test(page), 'the HTML names no command — the rows are generated');
158
158
  const shell = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
159
- assert.match(shell, /id: 'commands', label: 'Commands', icon: 'docs', href: '\/commands', gate: 'signed'/);
159
+ assert.match(shell, /id: 'commands', label: 'Commands', icon: 'commands', href: '\/commands', gate: 'signed'/);
160
160
  const serveInternal = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
161
161
  assert.match(serveInternal.match(/const SIGNEDIN_BUILDERS_PAGE_RE =\s*(\/.*\/);/)[1], /\|commands\)/);
162
162
  });
@@ -683,3 +683,45 @@ test('the badge is styled for BOTH shapes of the rail', () => {
683
683
  const drawer = css.slice(css.indexOf('html.drawer-open .app-sidebar'));
684
684
  assert.match(drawer, /\.nav-item__badge \{ top: 50%/, 'the mobile drawer position');
685
685
  });
686
+
687
+ // task 1004626: ONE GLYPH PER TAB. The rail is an icon strip until it opens,
688
+ // so at rest the icon is the tab's only name. Nine Government tabs once wore
689
+ // one shield and every Docs tab one page — the owner's word was "very
690
+ // confusing". Read off the RENDERED markup, so a fallback or a render bug that
691
+ // paints two tabs alike reds here even if the NAV keys differ.
692
+ test('every sidebar tab draws its own icon — no two tabs share a glyph', () => {
693
+ const html = boot().sidebarHtml;
694
+ const items = [...html.matchAll(/<a class="nav-item" id="nav-([^"]+)"[^>]*><svg class="nav-icon"[^>]*>([\s\S]*?)<\/svg>/g)]
695
+ .map((m) => ({ id: m[1], glyph: m[2] }));
696
+ assert.ok(items.length >= 20, `the nav items were actually located (${items.length})`);
697
+ const seen = new Map();
698
+ for (const { id, glyph } of items) {
699
+ assert.ok(glyph.trim(), `nav item ${id} draws a glyph (an unknown icon key renders an empty frame)`);
700
+ assert.ok(!seen.has(glyph), `nav items ${seen.get(glyph)} and ${id} draw the same icon`);
701
+ seen.set(glyph, id);
702
+ }
703
+ });
704
+
705
+ test('every NAV icon key is declared in ICONS, used once, and no two ICONS draw alike', () => {
706
+ const nav = SRC.slice(SRC.indexOf('var NAV = ['), SRC.indexOf('var NAV_COLLAPSE_KEY'));
707
+ const keys = [...nav.matchAll(/icon: '([^']+)'/g)].map((m) => m[1]);
708
+ assert.ok(keys.length >= 20, 'the NAV icon keys were actually located');
709
+ const block = SRC.slice(SRC.indexOf('var ICONS = {'), SRC.indexOf('function icon('));
710
+ const icons = new Map([...block.matchAll(/^\s+([a-z]+): '(.*)',$/gm)].map((m) => [m[1], m[2]]));
711
+ assert.ok(icons.size >= keys.length, 'the ICONS entries were actually located');
712
+ for (const k of keys) assert.ok(icons.has(k), `NAV icon '${k}' exists in ICONS`);
713
+ assert.equal(new Set(keys).size, keys.length, `no icon key is used by two tabs: ${keys.filter((k, i) => keys.indexOf(k) !== i).join(', ')}`);
714
+ const markup = [...icons.values()];
715
+ assert.equal(new Set(markup).size, markup.length, 'no two ICONS entries draw the same markup');
716
+ });
717
+
718
+ // task 1004639: glyph path data is shipped verbatim, and the release packager's
719
+ // leak scan reads four numbers chained on dots as a leaked address. The first
720
+ // thinking glyph chained four that way and refused the whole core release, so a new
721
+ // glyph separates its numbers with spaces wherever four would otherwise chain.
722
+ test('no glyph path data reads as an IP address to the release leak scan', () => {
723
+ const block = SRC.slice(SRC.indexOf('var ICONS = {'), SRC.indexOf('function icon('));
724
+ assert.ok(block.includes('thinking:'), 'the ICONS block was actually located');
725
+ const hits = block.match(/\d+\.\d+\.\d+\.\d+/g) || [];
726
+ assert.deepEqual(hits, [], 'separate the numbers with spaces so no four chain on dots');
727
+ });
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-08-city-tab/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-08-glyph-fix/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -657,6 +657,18 @@ try {
657
657
  assert.equal(lib.canFrame('https://builders.cloudbongos.com/studio', here), true);
658
658
  assert.equal(lib.canFrame('https://cloudbongos.com/', here), false, 'the apex is another origin');
659
659
  assert.equal(lib.canFrame('not a url', { origin: '', href: '' }), false);
660
+ // task 1004393: the apex and status pages split in place only once the proxy allows it.
661
+ assert.equal(lib.FRAME_SIBLINGS, false, 'off until the proxy sends frame-ancestors for the builders host');
662
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here), false, 'the default follows the switch');
663
+ assert.equal(lib.canFrame('https://cloudbongos.com/pricing', here, true), true, 'the apex, switched on');
664
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', here, true), true, 'the status host, switched on');
665
+ assert.equal(lib.canFrame('https://box.cloudbongos.com/', here, true), false, 'never another subdomain');
666
+ assert.equal(lib.canFrame('https://cloudbongos.com.evil.example/', here, true), false, 'never a lookalike host');
667
+ assert.equal(lib.canFrame('http://cloudbongos.com/', here, true), false, 'never the other scheme');
668
+ const local = { origin: 'http://builders.localhost:3000', href: 'http://builders.localhost:3000/tweak-editor' };
669
+ assert.equal(lib.canFrame('http://localhost:3000/', local, true), true, 'the port travels with the apex');
670
+ const bare = { origin: 'https://cloudbongos.com', href: 'https://cloudbongos.com/builders/tweak-editor' };
671
+ assert.equal(lib.canFrame('https://status.cloudbongos.com/', bare, true), false, 'only the builders host frames its siblings');
660
672
  assert.equal(lib.splitWindowFeatures({ availWidth: 1600, availHeight: 1000, availLeft: 0, availTop: 0 }), 'popup=yes,width=1600,height=500,left=0,top=500', 'the bottom half of the screen');
661
673
  assert.match(lib.splitWindowFeatures(undefined), /^popup=yes,width=\d+,height=\d+,left=0,top=\d+$/);
662
674
  });