@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.
- package/.bongos-core.json +108 -78
- package/.claude/skills/goal-create/SKILL.md +2 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +1 -0
- package/docs/adr/0370-a-repo-we-create-is-private.md +29 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +285 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +40 -33
- package/docs/copy-registry.json +125 -61
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +4 -2
- package/docs/page-readings.json +216 -207
- package/migrations/core_273_module_install_reports.sql +66 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-usage-reports.js +96 -0
- package/modules/hall-ui/public/settings.html +19 -1
- package/modules/hall-ui/public/settings.states.json +1 -0
- package/modules/hall-ui/public/shell.js +61 -29
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +22 -3
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/tweak-editor.md +1 -1
- package/modules/public-landing/public/projects.html +5 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/module-assess-installs.js +143 -0
- package/scripts/gds/module.js +29 -5
- package/scripts/hall-preview/live-identity.js +1 -0
- package/src/bongos/github-repos.js +7 -6
- package/src/bongos/module-usage-reports.js +112 -0
- package/src/bongos/routes/modules.js +96 -0
- package/src/module-api.js +1 -1
- package/tests/github_repos_helpers.mjs +5 -5
- package/tests/goal_map_page.mjs +1 -1
- package/tests/hall_commands.mjs +1 -1
- package/tests/hall_nav.mjs +42 -0
- package/tests/hall_tweak_editor.mjs +13 -1
- package/tests/module_assess_installs.mjs +272 -0
- package/tests/module_install.mjs +3 -0
- package/tests/module_update.mjs +3 -0
- package/tests/nav_permission_atoms.mjs +1 -1
package/scripts/gds/module.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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:
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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:
|
|
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
|
|
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
|
+
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:
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
assert.equal(sentBody.private,
|
|
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
|
});
|
package/tests/goal_map_page.mjs
CHANGED
|
@@ -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: '
|
|
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');
|
package/tests/hall_commands.mjs
CHANGED
|
@@ -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: '
|
|
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
|
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -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-
|
|
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
|
});
|