@bongos/core 1.18.18 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +515 -195
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +99 -14
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -1
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +124 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
package/tests/fitness.mjs CHANGED
@@ -4,11 +4,13 @@ import { test } from 'node:test';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { onDiskModuleKeys } from './helpers.mjs';
7
8
 
8
9
  const require = createRequire(import.meta.url);
9
10
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const fitness = require(path.join(ROOT, 'scripts', 'gds', 'fitness.js'));
11
12
 
13
+
12
14
  test('all fitness checks pass with no hard failures (the CI gate)', () => {
13
15
  const results = fitness.runAll();
14
16
  const failed = results.filter((r) => r.hardFail);
@@ -158,12 +160,26 @@ test('module boundary actually CATCHES violations when enforcing (ratchet flip)'
158
160
  test('core-carries-no-module-code: the live kernel machinery is clean', () => {
159
161
  const r = fitness.checkCoreCarriesNoModuleCode();
160
162
  assert.equal(r.hardFail, false, r.violations.join('\n'));
161
- // the check is live, not vacuous — it discovered the real module keys
162
- // (incl. the net-new `demo` proof module, BV1.R52 / task 1424, `ideas`, BV1.R78,
163
- // `security`, BV1.R79, `builder-settings`, BV1.R80, `onboarding`, BV1.R81,
164
- // `sessions`, BV1.R82, `autonomy`, BV1.R83, `status-ui`, BV1.R84,
165
- // `hall-ui`, BV1.R85, and the net-new `provisioning` feature module, ADR 0111)
166
- assert.deepEqual(fitness.discoveredModuleKeys().sort(), ['art-pipeline', 'autonomy', 'builder-settings', 'character-anim', 'demo', 'dev-box', 'discord', 'economy', 'game', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'provisioning', 'security', 'sessions', 'status-ui']);
163
+ // The check is live, not vacuous — it discovered the real module keys. This used to
164
+ // be a hardcoded 19-key roster that went stale at the CB→OTB split (it still named
165
+ // art-pipeline / character-anim / demo / game, and had never learned governance or
166
+ // platform-identity), so it asserted a topology that no longer existed — task 1002470.
167
+ // Derived from an INDEPENDENT readdir of modules/, so a discovery regression in
168
+ // fitness.js still diverges from disk and fails here.
169
+ assert.deepEqual(fitness.discoveredModuleKeys().sort(), onDiskModuleKeys());
170
+ });
171
+
172
+ test('the discovered-module roster is derived from a NON-EMPTY disk read (mutation check)', () => {
173
+ // Guards the check above against going vacuous: if modules/ read back empty, the
174
+ // deepEqual would compare [] to [] and pass while proving nothing.
175
+ const keys = onDiskModuleKeys();
176
+ assert.ok(keys.length >= 5, `expected a real on-disk module roster, found ${keys.length}: ${keys.join(', ')}`);
177
+ // And it still FAILS when discovery actually drops a module.
178
+ assert.throws(
179
+ () => assert.deepEqual(keys.slice(1), keys),
180
+ /Expected values to be strictly deep-equal/,
181
+ 'a dropped module must still fail the roster comparison',
182
+ );
167
183
  });
168
184
 
169
185
  test('core-carries-no-module-code actually CATCHES a hardcoded key (and ignores comments)', () => {
@@ -407,6 +423,10 @@ test('hall zone budgets check actually CATCHES a breach (not a no-op)', () => {
407
423
  // (a sibling-module import, and an ungated module write route).
408
424
 
409
425
  test('demo proof module is live + clean (boundary + rank + migration green)', () => {
426
+ // modules/demo/ is the acceptance-proof module; an instance that does not install it
427
+ // has nothing to prove here (task 1002470). The RED-LINE tests below are pure-function
428
+ // checks on path strings and still run either way, so the one-way rules stay covered.
429
+ if (!onDiskModuleKeys().includes('demo')) return;
410
430
  // discovered with the other modules (asserted above) and clean on every gate.
411
431
  assert.ok(fitness.discoveredModuleKeys().includes('demo'), 'demo is a discovered module');
412
432
  assert.equal(fitness.checkModulesImportOnlyApi().hardFail, false, 'demo imports only the doorway');
@@ -438,11 +438,24 @@ await t('both routes are requireBuilder-gated and registered', async () => {
438
438
  }
439
439
  });
440
440
 
441
- await t('route-rank auditor: both gate-approval routes classify to archon with no findings', async () => {
441
+ // ADR 0157 SPLIT these two apart: reading the held-PR list is `gate_approval.read`
442
+ // (metic — the Gate page opens to the working rank, and a page whose only data endpoint
443
+ // 403s is a page that renders an error), while APPROVING still clears the gate-review
444
+ // hard floor and stays `gate_approval.decide` (archon, system:true). Asserting the split
445
+ // explicitly is the point — a future edit that re-merged them onto one atom would either
446
+ // close the page again or hand out the approval, and both are caught here.
447
+ await t('route-rank auditor: gate-approval READ is metic+, APPROVE stays archon, no findings', async () => {
442
448
  const res = checkRouteRanks();
443
449
  const mine = res.routes.filter((r) => r.route.includes('gate-approvals'));
444
450
  assert.equal(mine.length, 2, 'both routes seen by the auditor');
445
- for (const r of mine) assert.equal(r.rank, 'archon', `${r.key} must classify archon`);
451
+ const byKey = new Map(mine.map((r) => [r.key, r]));
452
+ const read = byKey.get('GET /gate-approvals');
453
+ const approve = byKey.get('POST /gate-approvals/:pr/approve');
454
+ assert.ok(read && approve, 'both the list and the approve route must be present');
455
+ assert.deepEqual(read.perms, ['gate_approval.read'], 'the list route gates on the read atom');
456
+ assert.equal(read.rank, 'metic+archon', 'the list route is metic+ (ADR 0157)');
457
+ assert.deepEqual(approve.perms, ['gate_approval.decide'], 'approve gates on the decide atom');
458
+ assert.equal(approve.rank, 'archon', 'approving a held PR must stay archon');
446
459
  const myFindings = res.findings.filter((f) => (f.route || '').includes('gate-approvals'));
447
460
  assert.deepEqual(myFindings, [], 'no auditor findings on the gate-approval routes');
448
461
  });
@@ -77,10 +77,12 @@ test('landProvenForTask: published tip NOT in main → NOT proven (the false-shi
77
77
  assert.match(r.reason, /not in main/);
78
78
  });
79
79
 
80
- test('landProvenForTask: no published tip but matched PR references this task → proven (legacy fallback)', async () => {
80
+ test('landProvenForTask: no published tip but matched MERGED PR references this task → proven (legacy fallback)', async () => {
81
81
  const r = await gh.landProvenForTask(
82
- { taskId: 999, publishedSha: null, status: { pr_body: 'for task 999' } },
83
- depsWithCompare('behind') // must NOT be consulted — no sha
82
+ // merged:true mirrors how real callers reach this fallback (post-landConfirmed);
83
+ // task 1002615 made it a hard requirement (see the open-PR test below).
84
+ { taskId: 999, publishedSha: null, status: { merged: true, pr_body: 'for task 999' } },
85
+ depsWithCompare('behind') // must NOT be consulted — no sha anywhere
84
86
  );
85
87
  assert.equal(r.proven, true);
86
88
  assert.match(r.reason, /references task 999/);
@@ -88,12 +90,56 @@ test('landProvenForTask: no published tip but matched PR references this task
88
90
 
89
91
  test('landProvenForTask: no published tip and matched PR references a DIFFERENT task → NOT proven', async () => {
90
92
  const r = await gh.landProvenForTask(
91
- { taskId: 1299, publishedSha: null, status: { pr_title: 'task 1297: overlay', pr_body: 'for task 1297' } },
93
+ { taskId: 1299, publishedSha: null, status: { merged: true, pr_title: 'task 1297: overlay', pr_body: 'for task 1297' } },
92
94
  depsWithCompare('behind')
93
95
  );
94
96
  assert.equal(r.proven, false);
95
97
  });
96
98
 
99
+ // --- task 1002615: the out-of-band / rewritten-history land proofs -----------
100
+
101
+ // Per-sha compare responses, so the tip and the merge commit can answer differently.
102
+ function depsWithCompareBySha(map) {
103
+ return {
104
+ token: 'x',
105
+ repoInfo: { owner: 'o', repo: 'r' },
106
+ fetchImpl: async (url) => {
107
+ const m = /compare\/[^.]+\.\.\.([0-9a-f]{7,64})/.exec(String(url));
108
+ return { ok: true, status: 200, json: async () => ({ status: (m && map[m[1]]) || 'ahead' }) };
109
+ },
110
+ };
111
+ }
112
+
113
+ const MERGE_SHA = 'f'.repeat(40);
114
+
115
+ test('landProvenForTask (1002615): squash merge — tip NOT in main but the merged PR\'s merge_sha is → proven', async () => {
116
+ const r = await gh.landProvenForTask(
117
+ { taskId: 7, publishedSha: SHA,
118
+ status: { merged: true, merge_sha: MERGE_SHA, pr_title: 'task 7: thing' } },
119
+ depsWithCompareBySha({ [SHA]: 'ahead', [MERGE_SHA]: 'behind' })
120
+ );
121
+ assert.equal(r.proven, true, 'a squash/rebase land must not strand on the tip check');
122
+ assert.match(r.reason, /merged PR for task 7/);
123
+ });
124
+
125
+ test('landProvenForTask (1002615): an OPEN PR that references the task is NOT a land (merged:true required)', async () => {
126
+ const r = await gh.landProvenForTask(
127
+ { taskId: 8, publishedSha: null, status: { merged: false, pr_state: 'open', pr_body: 'for task 8' } },
128
+ depsWithCompare('behind')
129
+ );
130
+ assert.equal(r.proven, false, 'reference alone on an un-merged PR must never prove');
131
+ });
132
+
133
+ test('landProvenForTask (1002615): merged PR for a DIFFERENT task + tip not in main → NOT proven (reuse guard intact)', async () => {
134
+ const r = await gh.landProvenForTask(
135
+ { taskId: 7, publishedSha: SHA,
136
+ status: { merged: true, merge_sha: MERGE_SHA, pr_title: 'task 999: other' } },
137
+ depsWithCompareBySha({ [SHA]: 'ahead', [MERGE_SHA]: 'behind' })
138
+ );
139
+ assert.equal(r.proven, false);
140
+ assert.match(r.reason, /not in main/);
141
+ });
142
+
97
143
  test('landProvenForTask: a non-sha published tip ("HEAD") falls back to the PR check, never the compare API', async () => {
98
144
  let compareCalled = false;
99
145
  const deps = {
@@ -17,12 +17,34 @@ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
17
17
  const routesAuth = require(path.join(ROOT, 'src/bongos/routes/auth.js'));
18
18
  const gh = require(path.join(ROOT, 'src/bongos/github-repos.js'));
19
19
 
20
- test('scopeForWebFlow: identity-only by default, adds public_repo ONLY when repos=true', () => {
20
+ test('scopeForWebFlow: identity-only by default, adds repo ONLY when repos=true', () => {
21
21
  assert.equal(auth.scopeForWebFlow(), 'read:user');
22
22
  assert.equal(auth.scopeForWebFlow({ repos: false }), 'read:user');
23
- assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user public_repo');
24
- // Never the all-or-nothing `repo` scope (ADR 0141) — public_repo only.
25
- assert.ok(!auth.scopeForWebFlow({ repos: true }).split(' ').includes('repo'));
23
+ // ADR 0155 (task 1002561): `repo`, not `public_repo` — public_repo grants write to
24
+ // PUBLIC repos only, so adopting an existing PRIVATE repo 403'd at the scaffold push.
25
+ assert.equal(auth.scopeForWebFlow({ repos: true }), 'read:user repo');
26
+ // The widening is opt-in and MUST stay opt-in: an ordinary hall sign-in / the CLI
27
+ // device flow must never carry a repo-acting scope.
28
+ assert.ok(!auth.scopeForWebFlow().split(' ').includes('repo'));
29
+ });
30
+
31
+ test('grantedScopeCoversRepos: a stale pre-0155 public_repo token is NOT sufficient', () => {
32
+ // The exact shape GitHub stored for the live failure (github_oauth_tokens.scope):
33
+ // comma-separated, GitHub's own order, and `public_repo` CONTAINS the substring
34
+ // "repo" — a naive match would wrongly accept it and reintroduce the silent
35
+ // "private repos missing from the picker" degrade.
36
+ assert.equal(auth.grantedScopeCoversRepos('public_repo,read:user'), false);
37
+ assert.equal(auth.grantedScopeCoversRepos('read:user'), false);
38
+ assert.equal(auth.grantedScopeCoversRepos(''), false);
39
+ assert.equal(auth.grantedScopeCoversRepos(null), false);
40
+ assert.equal(auth.grantedScopeCoversRepos(undefined), false);
41
+ // Sufficient: `repo` present, in either order / separator GitHub might use.
42
+ assert.equal(auth.grantedScopeCoversRepos('read:user,repo'), true);
43
+ assert.equal(auth.grantedScopeCoversRepos('repo,read:user'), true);
44
+ assert.equal(auth.grantedScopeCoversRepos('read:user repo'), true);
45
+ assert.equal(auth.grantedScopeCoversRepos('repo'), true);
46
+ // `repo` implies public_repo, so a token carrying both is fine too.
47
+ assert.equal(auth.grantedScopeCoversRepos('public_repo,repo,read:user'), true);
26
48
  });
27
49
 
28
50
  test('safeReturnTo still guards after the ?repos change: relative ok, cross-origin/protocol-relative blocked', () => {
@@ -77,7 +99,10 @@ test('createUserRepo: sends auto_init:false + private:false, returns full_name/h
77
99
  };
78
100
  const out = await gh.createUserRepo('tok', { name: 'mercury', fetchImpl });
79
101
  assert.equal(sentBody.auto_init, false, 'empty repo — no README/first commit');
80
- assert.equal(sentBody.private, false, 'public_repo scope → public');
102
+ // Still public AFTER the ADR 0155 scope widen: `repo` COULD create a private repo,
103
+ // but a repo Cloud Bongos creates stays public by product choice (open-source /
104
+ // capture-resistant by design). Guards against the widen silently changing what we make.
105
+ assert.equal(sentBody.private, false, 'a repo WE create is public by product choice');
81
106
  assert.equal(sentBody.name, 'mercury');
82
107
  assert.deepEqual(out, { full_name: 'me/mercury', html_url: 'https://github.com/me/mercury' });
83
108
  });
@@ -119,3 +119,26 @@ test('GET /github/repos → 403 repo_scope_required when configured but no store
119
119
  db.getGithubToken = orig;
120
120
  }
121
121
  });
122
+
123
+ // ADR 0155 / task 1002561. Widening scopeForWebFlow to `repo` does NOT upgrade rows
124
+ // already in github_oauth_tokens. A surviving pre-0155 `public_repo` token can still
125
+ // list + create PUBLIC repos, so WITHOUT the scope check the route 200s while silently
126
+ // omitting the user's private repos — and the real failure resurfaces minutes later as
127
+ // an opaque 403 on the provisioner's push. It must take the reconnect path instead.
128
+ // (No 200-path test here: `repo`-scoped tokens fall through to a real GitHub call, and
129
+ // this file is deliberately network-free. The positive direction is covered exhaustively
130
+ // by grantedScopeCoversRepos in tests/github_repos_helpers.mjs.)
131
+ test('GET /github/repos → 403 repo_scope_required for a STALE pre-0155 public_repo token', async () => {
132
+ const orig = db.getGithubToken;
133
+ db.getGithubToken = async () => ({ token: 'gho_stale', scope: 'public_repo,read:user' });
134
+ try {
135
+ const handler = finalHandler(buildGithubRouter(), 'get', '/github/repos');
136
+ const res = fakeRes();
137
+ await handler({ builder: { id: 1 }, query: {} }, res);
138
+ assert.equal(res._status, 403, 'a public_repo-only token must not be treated as sufficient');
139
+ assert.equal(res._body.error.code, 'repo_scope_required');
140
+ assert.match(res._body.error.message, /private/i, 'tells the user WHY reconnecting is needed');
141
+ } finally {
142
+ db.getGithubToken = orig;
143
+ }
144
+ });
@@ -51,7 +51,7 @@ const buildGoalsRouter = require('../modules/lifecycle/routes/goals.js');
51
51
  // The pure, exported, ordered create gate — the one place the decision is made.
52
52
  const { authorizeGoalTaskCreate } = buildGoalsRouter;
53
53
  const { deriveRequiredRank, highestRank } = require('../modules/lifecycle/db.js');
54
- const { MODULE_EXPECTED_RANKS, checkRouteRanks } = require('../src/bongos/route-rank-check.js');
54
+ const { MODULE_EXPECTED_RANKS, checkRouteRanks, pinMismatch } = require('../src/bongos/route-rank-check.js');
55
55
  const { scopeIncludesProtected, protectedModules, isModuleKey } = require('../src/bongos/module-scope-map.js');
56
56
 
57
57
  const goalsSrc = readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
@@ -169,7 +169,12 @@ await test('[pin] every goal route is pinned in MODULE_EXPECTED_RANKS.lifecycle'
169
169
  assert.equal(pins['POST /goals'], 'metic+archon');
170
170
  assert.equal(pins['POST /goals/:id/members'], 'any-builder', 'task 1762: widened to owner|lead|archon, in-handler gate');
171
171
  assert.equal(pins['POST /goals/:id/scope'], 'metic+archon');
172
- assert.equal(pins['POST /goals/reorder'], 'archon', 'priority is an owner decision, never self-granted');
172
+ // ADR 0157 lowered `goal.reorder` archon→metic and re-pinned it by atom. Ordering the
173
+ // queue is prioritisation, not an authority grant — the "never self-granted" property
174
+ // that matters here is that reorder is NOT reachable through a goal's own
175
+ // owner/manager authority, and that still holds: it needs a global permission no
176
+ // member role confers.
177
+ assert.equal(pins['POST /goals/reorder'], 'perm:goal.reorder', 'reorder needs a global permission, never a goal-member grant');
173
178
  });
174
179
  await test('[pin] LIVE: every pinned goal route classifies to its expected rank (a drift would mismatch)', () => {
175
180
  const res = checkRouteRanks();
@@ -180,7 +185,12 @@ await test('[pin] LIVE: every pinned goal route classifies to its expected rank
180
185
  if (!key.includes('/goals')) continue; // this suite owns the goal-route pins
181
186
  const r = byKey.get(key);
182
187
  assert.ok(r, `pinned goal route ${key} must exist in the live route set`);
183
- assert.equal(r.rank, expected, `goal route ${key} must classify as '${expected}', got '${r.rank}'`);
188
+ // A pin is either a rank label or `perm:<atom>[,<atom>]` (ADR 0151 / ADR 0157). For an
189
+ // atom pin the live row carries the ATOMS, and its rank label is derived from the
190
+ // catalog floor — so compare atoms to atoms, ranks to ranks. Reusing the checker's own
191
+ // pinMismatch keeps this suite from re-implementing (and drifting from) that rule.
192
+ const mismatch = pinMismatch(expected, r);
193
+ assert.equal(mismatch, null, `goal route ${key} ${mismatch}`);
184
194
  }
185
195
  });
186
196
 
@@ -0,0 +1,89 @@
1
+ // tests/goal_cli.mjs
2
+ //
3
+ // Unit test for the PURE helpers exported from scripts/gds/goal.js (the goal
4
+ // board CLI): rollup(), criterionGlyph(), criterionState(), bucketTasks(),
5
+ // liveTasks(). All are deterministic with no DB, network, or live server —
6
+ // requiring the script must NOT run the CLI (main is require.main-guarded).
7
+ // Run: node tests/goal_cli.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const goal = require('../scripts/gds/goal.js');
14
+
15
+ let passed = 0;
16
+ let failed = 0;
17
+ function test(name, fn) {
18
+ try { fn(); passed++; console.log(` ok ${name}`); }
19
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
20
+ }
21
+
22
+ // All EIGHT statuses of migration 012's CHECK constraint.
23
+ const ALL_STATUSES = ['backlog', 'ready', 'active', 'blocked', 'completed', 'confirmed', 'shipped', 'abandoned'];
24
+ const t = (status) => ({ status });
25
+
26
+ test('rollup: counts shipped over total', () => {
27
+ assert.equal(goal.rollup([t('shipped'), t('shipped'), t('ready')]), '2/3 shipped · 1 ready');
28
+ });
29
+
30
+ test('rollup: in-flight covers active/completed/confirmed', () => {
31
+ assert.equal(goal.rollup([t('active'), t('completed'), t('confirmed')]), '0/3 shipped · 3 in flight');
32
+ });
33
+
34
+ test('rollup: omits zero segments (backlog-only goal shows only shipped fraction)', () => {
35
+ assert.equal(goal.rollup([t('backlog'), t('backlog')]), '0/2 shipped');
36
+ });
37
+
38
+ test('rollup: abandoned is excluded from the denominator (migration 017 rule)', () => {
39
+ // Without the exclusion this would read "2/3 shipped" for a goal with no live work left.
40
+ assert.equal(goal.rollup([t('shipped'), t('shipped'), t('abandoned')]), '2/2 shipped');
41
+ });
42
+
43
+ test('liveTasks: filters only abandoned', () => {
44
+ const live = goal.liveTasks(ALL_STATUSES.map(t));
45
+ assert.equal(live.length, ALL_STATUSES.length - 1);
46
+ assert.ok(live.every((x) => x.status !== 'abandoned'));
47
+ });
48
+
49
+ test('bucketTasks: partitions all 8 lifecycle statuses into exactly one bucket', () => {
50
+ const tasks = ALL_STATUSES.map(t);
51
+ const b = goal.bucketTasks(tasks);
52
+ assert.equal(b.shipped.length, 1);
53
+ assert.equal(b.inFlight.length, 3);
54
+ assert.equal(b.ready.length, 1);
55
+ assert.equal(b.gated.length, 2); // backlog + blocked
56
+ assert.equal(b.abandoned.length, 1);
57
+ const total = b.shipped.length + b.inFlight.length + b.ready.length + b.gated.length + b.abandoned.length;
58
+ assert.equal(total, tasks.length, 'no status may fall through the buckets');
59
+ });
60
+
61
+ test('criterionGlyph: satisfied wins over pending_review', () => {
62
+ assert.equal(goal.criterionGlyph({ satisfied: true, pending_review: true }), '✓');
63
+ assert.equal(goal.criterionGlyph({ satisfied: false, pending_review: true }), '◇');
64
+ assert.equal(goal.criterionGlyph({ satisfied: false, pending_review: false }), '○');
65
+ });
66
+
67
+ test('criterionState: pending review names the /goal-review queue', () => {
68
+ assert.match(goal.criterionState({ satisfied: false, pending_review: true }), /pending review/);
69
+ });
70
+
71
+ test('criterionState: open criterion reports unshipped-of-linked counts (string API ids)', () => {
72
+ // The API returns counts as strings — the formatter must not NaN on them.
73
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '3', linked_tasks: '3' });
74
+ assert.equal(s, '3 of 3 linked tasks unshipped');
75
+ });
76
+
77
+ test('criterionState: singular linked task reads grammatically', () => {
78
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '1', linked_tasks: '1' });
79
+ assert.equal(s, '1 of 1 linked task unshipped');
80
+ });
81
+
82
+ test('criterionState: zero linked tasks must NOT read as complete', () => {
83
+ const s = goal.criterionState({ satisfied: false, pending_review: false, unshipped_tasks: '0', linked_tasks: '0' });
84
+ assert.equal(s, 'no linked tasks yet');
85
+ });
86
+
87
+ console.log('');
88
+ console.log(`${passed} passed, ${failed} failed`);
89
+ if (failed > 0) process.exit(1);