@bevel-software/platform-core-backend 0.21.0 → 0.23.0

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 (434) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,359 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import express from 'express';
3
+ import type { IAdminAccessService } from '../admin/admin.interface.js';
4
+ import { logger } from '../../shared/logging.js';
5
+ import type { DeploymentSettingsService } from '../settings/deployment-settings.service.js';
6
+ import { GitHubAppClient, GitHubAppError } from './github-app.client.js';
7
+ import { repositoriesAsSetting, type GitHubAppConnection } from './github-app.connection.js';
8
+ import '../auth/auth.middleware.js'; // Express Request augmentation
9
+
10
+ const log = logger('github-app');
11
+
12
+ const STATE_COOKIE = 'hexis_github_state';
13
+ const STATE_MAX_AGE_S = 15 * 60;
14
+ /** A GitHub organisation's name, as it appears in an address. */
15
+ const ORGANIZATION_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
16
+ /** GitHub refuses an app name longer than this. */
17
+ const MAX_APP_NAME = 34;
18
+
19
+ export interface GitHubAppRoutesDeps {
20
+ settings: Pick<DeploymentSettingsService, 'resolve' | 'record'>;
21
+ connection: GitHubAppConnection;
22
+ adminAccess: IAdminAccessService;
23
+ /** Where GitHub sends the browser back to: this deployment's own address. */
24
+ publicBackendUrl: string;
25
+ publicFrontendUrl: string;
26
+ /** Whether the deployment is set up: decides which page a round trip ends on. */
27
+ isComplete(): boolean;
28
+ client?: GitHubAppClient;
29
+ }
30
+
31
+ /** The address GitHub is told to send the browser back to once the app is installed. */
32
+ export function githubAppCallbackUrl(publicBackendUrl: string): string {
33
+ return `${publicBackendUrl}/api/setup/github-app/callback`;
34
+ }
35
+
36
+ /**
37
+ * The manifest a deployment's own GitHub App is created from. It asks for
38
+ * what the deployment does and nothing else: read and write the contents of
39
+ * the repositories it is installed on. No webhook, no events, not public:
40
+ * the app is this deployment's, installed by its owner on the one
41
+ * repository the knowledge base lives in.
42
+ */
43
+ export function githubAppManifest(publicBackendUrl: string, publicFrontendUrl: string): Record<string, unknown> {
44
+ const host = new URL(publicFrontendUrl).host;
45
+ return {
46
+ name: `Hexis ${host}`.slice(0, MAX_APP_NAME),
47
+ url: publicFrontendUrl,
48
+ description: `Lets the Hexis deployment at ${host} read and write the repository its knowledge base lives in.`,
49
+ redirect_url: `${publicBackendUrl}/api/setup/github-app/registered`,
50
+ callback_urls: [githubAppCallbackUrl(publicBackendUrl)],
51
+ // GitHub then sends the person back signed in, which is what lets the
52
+ // deployment check that the installation they name is one they hold.
53
+ request_oauth_on_install: true,
54
+ public: false,
55
+ default_permissions: { contents: 'write', metadata: 'read' },
56
+ default_events: [],
57
+ };
58
+ }
59
+
60
+ function readCookie(req: express.Request, name: string): string | null {
61
+ for (const segment of (req.headers.cookie ?? '').split(';')) {
62
+ const trimmed = segment.trim();
63
+ if (!trimmed.startsWith(`${name}=`)) continue;
64
+ try {
65
+ return decodeURIComponent(trimmed.slice(name.length + 1)) || null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function same(a: string, b: string): boolean {
74
+ const left = Buffer.from(a, 'utf8');
75
+ const right = Buffer.from(b, 'utf8');
76
+ return left.length === right.length && timingSafeEqual(left, right);
77
+ }
78
+
79
+ /**
80
+ * Connecting a repository on GitHub through a GitHub App, from the setup
81
+ * screen. Mounted behind the deployment's sign-in; every route is an
82
+ * admin's.
83
+ *
84
+ * GET /setup/github-app where the connection stands
85
+ * POST /setup/github-app/manifest start registering this deployment's own app
86
+ * GET /setup/github-app/registered GitHub sends the browser back: the app exists
87
+ * POST /setup/github-app/install where on GitHub the app is installed
88
+ * POST /setup/github-app/refresh where to sign in, to read again what may be connected
89
+ * GET /setup/github-app/callback GitHub sends the browser back: the app is installed
90
+ * GET /setup/github-app/repositories what the deployment may be pointed at
91
+ *
92
+ * A ROUND TRIP IS STARTED BY A POST AND ENDED BY A GET. The two routes
93
+ * GitHub sends the browser to are ordinary navigations, authenticated by
94
+ * the session cookie; each checks a state this browser was given when the
95
+ * round trip started, and spends it. Nothing that starts one is a link, so
96
+ * a link somebody was sent starts nothing.
97
+ *
98
+ * WHAT THE DEPLOYMENT MAY REACH is never taken from the address the browser
99
+ * came back on. GitHub puts an installation's number there and anyone can
100
+ * write another: with one app serving many deployments, that number is the
101
+ * only thing between a deployment and another organisation's repositories.
102
+ * GitHub sends the person back signed in, and the deployment asks GitHub
103
+ * two things about THAT PERSON: which installations they reach, and which
104
+ * repositories of the one they named they can push to with their own
105
+ * account. The first alone is not enough, since read access to one
106
+ * repository puts an installation on that list. Only the second is what
107
+ * the deployment may be pointed at.
108
+ */
109
+ export function createGitHubAppRoutes(deps: GitHubAppRoutesDeps): express.Router {
110
+ const router = express.Router();
111
+ const client = deps.client ?? new GitHubAppClient();
112
+ const { settings, connection } = deps;
113
+ const secure = deps.publicBackendUrl.startsWith('https');
114
+
115
+ const requireAdmin: express.RequestHandler = async (req, res, next) => {
116
+ if (!(await deps.adminAccess.isAdmin(req.userEmail))) {
117
+ res.status(403).json({ error: 'Admins only' });
118
+ return;
119
+ }
120
+ next();
121
+ };
122
+
123
+ /** A state for one round trip, kept in this browser until it comes back. */
124
+ const beginRoundTrip = (res: express.Response): string => {
125
+ const tag = settings.resolve('githubAppStateTag');
126
+ const state = `${tag ? `${tag}.` : ''}${randomBytes(24).toString('base64url')}`;
127
+ res.cookie(STATE_COOKIE, state, { httpOnly: true, sameSite: 'lax', secure, maxAge: STATE_MAX_AGE_S * 1000, path: '/' });
128
+ return state;
129
+ };
130
+ /** Whether the browser that came back is the one that was sent. */
131
+ const cameBack = (req: express.Request): boolean => {
132
+ const kept = readCookie(req, STATE_COOKIE);
133
+ const given = typeof req.query.state === 'string' ? req.query.state : '';
134
+ return Boolean(kept && given && same(kept, given));
135
+ };
136
+ /** Back to the setup screen, saying how the round trip ended. */
137
+ const finish = (res: express.Response, outcome: string): void => {
138
+ const page = deps.isComplete() ? '/deployment' : '/';
139
+ res.redirect(`${deps.publicFrontendUrl}${page}?github=${encodeURIComponent(outcome)}`);
140
+ };
141
+ const installUrl = (slug: string, state: string): string =>
142
+ `https://github.com/apps/${encodeURIComponent(slug)}/installations/new?state=${encodeURIComponent(state)}`;
143
+ const outcomeOf = (err: unknown): string =>
144
+ err instanceof GitHubAppError && err.kind === 'unreachable' ? 'unreachable' : 'refused';
145
+
146
+ router.get('/setup/github-app', requireAdmin, (_req, res) => {
147
+ const credentials = connection.credentials();
148
+ res.json({
149
+ // Who supplied the app. Null: this deployment has none yet, and can register its own.
150
+ registeredBy: connection.registeredBy(),
151
+ app: credentials ? { slug: credentials.slug, url: `https://github.com/apps/${credentials.slug}` } : null,
152
+ installation: connection.installationId()
153
+ ? { id: connection.installationId(), account: settings.resolve('githubInstallationAccount') }
154
+ : null,
155
+ repository: settings.resolve('githubRepository') || null,
156
+ });
157
+ });
158
+
159
+ router.post('/setup/github-app/manifest', requireAdmin, (req, res) => {
160
+ if (connection.credentials()) {
161
+ res.status(409).json({ error: 'This deployment already has a GitHub App.' });
162
+ return;
163
+ }
164
+ const organization = (req.body as { organization?: unknown } | undefined)?.organization;
165
+ const org = typeof organization === 'string' ? organization.trim() : '';
166
+ if (org && !ORGANIZATION_PATTERN.test(org)) {
167
+ res.status(400).json({ error: 'Enter the organisation’s name as it appears in its GitHub address.' });
168
+ return;
169
+ }
170
+ const state = beginRoundTrip(res);
171
+ const base = org ? `https://github.com/organizations/${org}/settings/apps/new` : 'https://github.com/settings/apps/new';
172
+ // The browser posts the manifest to GitHub itself: a form, not a request of ours.
173
+ res.json({
174
+ action: `${base}?state=${encodeURIComponent(state)}`,
175
+ manifest: githubAppManifest(deps.publicBackendUrl, deps.publicFrontendUrl),
176
+ });
177
+ });
178
+
179
+ router.get('/setup/github-app/registered', requireAdmin, async (req, res) => {
180
+ // Spent on return, however the return ends.
181
+ const returned = cameBack(req);
182
+ res.clearCookie(STATE_COOKIE, { path: '/' });
183
+ if (!returned) {
184
+ finish(res, 'state');
185
+ return;
186
+ }
187
+ const code = typeof req.query.code === 'string' ? req.query.code : '';
188
+ if (!code || connection.credentials()) {
189
+ finish(res, code ? 'already-registered' : 'refused');
190
+ return;
191
+ }
192
+ try {
193
+ const app = await client.convertManifest(code);
194
+ await settings.record(
195
+ {
196
+ githubAppId: app.appId,
197
+ githubAppSlug: app.slug,
198
+ githubAppPrivateKey: app.privateKey,
199
+ githubAppClientId: app.clientId,
200
+ githubAppClientSecret: app.clientSecret,
201
+ },
202
+ req.userId ?? null,
203
+ );
204
+ log.info(`GitHub App "${app.slug}" registered for this deployment`, { slug: app.slug, owner: app.owner });
205
+ // Straight on to installing it: the same round trip, the same state.
206
+ res.redirect(installUrl(app.slug, beginRoundTrip(res)));
207
+ } catch (err) {
208
+ log.error('the GitHub App could not be registered:', { detail: err instanceof Error ? err.message : String(err) });
209
+ finish(res, outcomeOf(err));
210
+ }
211
+ });
212
+
213
+ /**
214
+ * A POST that answers with the address, and the page sends the browser
215
+ * there. As a link it was a round trip anyone could start in an admin's
216
+ * browser by getting them to follow it: the state would be set, and
217
+ * whatever they then installed would come back with a state that matched.
218
+ */
219
+ router.post('/setup/github-app/install', requireAdmin, (_req, res) => {
220
+ const credentials = connection.credentials();
221
+ if (!credentials) {
222
+ res.status(409).json({ error: 'This deployment has no GitHub App yet. Create it first.' });
223
+ return;
224
+ }
225
+ res.json({ url: installUrl(credentials.slug, beginRoundTrip(res)) });
226
+ });
227
+
228
+ /**
229
+ * Ask GitHub again what the person may connect, WITHOUT installing
230
+ * anything: a sign-in with the app and nothing more, which comes back on
231
+ * the same callback with a code and no installation named.
232
+ *
233
+ * It exists because installing comes back only the first time. Once the
234
+ * app is installed, the address that installs it opens the installation's
235
+ * settings on GitHub, and GitHub sends nobody back from there: a
236
+ * repository added on that page was reached by the app and never offered
237
+ * here, since what is offered is what was recorded when the person
238
+ * connected. The same goes for an installation an organisation's owner
239
+ * approved later, in a browser of their own.
240
+ */
241
+ router.post('/setup/github-app/refresh', requireAdmin, (_req, res) => {
242
+ const credentials = connection.credentials();
243
+ if (!credentials) {
244
+ res.status(409).json({ error: 'This deployment has no GitHub App yet. Create it first.' });
245
+ return;
246
+ }
247
+ const state = beginRoundTrip(res);
248
+ res.json({
249
+ url: `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(credentials.clientId)}&state=${encodeURIComponent(state)}`,
250
+ });
251
+ });
252
+
253
+ router.get('/setup/github-app/callback', requireAdmin, async (req, res) => {
254
+ const returned = cameBack(req);
255
+ res.clearCookie(STATE_COOKIE, { path: '/' });
256
+ if (!returned) {
257
+ finish(res, 'state');
258
+ return;
259
+ }
260
+ const credentials = connection.credentials();
261
+ if (!credentials) {
262
+ finish(res, 'not-registered');
263
+ return;
264
+ }
265
+ // Someone without the right to install asked their organisation's owner
266
+ // to: nothing is installed yet, and there is nothing to record.
267
+ if (req.query.setup_action === 'request') {
268
+ finish(res, 'requested');
269
+ return;
270
+ }
271
+ const code = typeof req.query.code === 'string' ? req.query.code : '';
272
+ const given = typeof req.query.installation_id === 'string' ? req.query.installation_id : '';
273
+ if (!code || (given !== '' && !/^\d+$/.test(given))) {
274
+ finish(res, 'refused');
275
+ return;
276
+ }
277
+ // Back from installing, GitHub names the installation. Back from a
278
+ // sign-in alone (see `refresh`) it names none, and the one in question
279
+ // is the one this deployment has. Either way it is only a name: what
280
+ // decides is what GitHub says the person reaches, below.
281
+ const refreshing = given === '';
282
+ const named = given || connection.installationId();
283
+ try {
284
+ const userToken = await client.exchangeUserCode(credentials, code);
285
+ const reached = await client.installationsOf(userToken);
286
+ let held = named ? reached.find((installation) => installation.id === named) : undefined;
287
+ if (!named) {
288
+ // No installation on record and none named: the app was installed
289
+ // where this browser never came back from (an owner approved it
290
+ // later). One the person reaches is the one; several are a choice
291
+ // only installing from here can make.
292
+ if (reached.length === 0) {
293
+ finish(res, 'not-installed');
294
+ return;
295
+ }
296
+ if (reached.length > 1) {
297
+ finish(res, 'several');
298
+ return;
299
+ }
300
+ held = reached[0];
301
+ }
302
+ if (!held) {
303
+ log.warn('an installation was named that the person who came back cannot reach', { installation: named });
304
+ finish(res, 'not-yours');
305
+ return;
306
+ }
307
+ // Reaching the installation is not holding it: read access to one
308
+ // repository it covers is enough for GitHub to list it. What the
309
+ // deployment may be pointed at is what THIS PERSON can push to.
310
+ const theirs = await client.repositoriesOf(userToken, held.id);
311
+ const writable = theirs.repositories.filter((repository) => repository.writable).map((r) => r.fullName);
312
+ if (writable.length === 0) {
313
+ log.warn('the person who came back can push to nothing the installation covers', { installation: held.id });
314
+ finish(res, 'nothing-to-write');
315
+ return;
316
+ }
317
+ await settings.record(
318
+ {
319
+ githubInstallationId: held.id,
320
+ githubRepositoriesPermitted: repositoriesAsSetting(writable),
321
+ ...(held.account ? { githubInstallationAccount: held.account } : {}),
322
+ },
323
+ req.userId ?? null,
324
+ );
325
+ // A token in hand was another installation's.
326
+ connection.forget();
327
+ log.info(
328
+ refreshing
329
+ ? `what may be connected through the GitHub App on "${held.account}" was read again`
330
+ : `GitHub App installed on "${held.account}"`,
331
+ { installation: held.id, account: held.account, repositories: writable.length },
332
+ );
333
+ finish(res, refreshing ? 'refreshed' : 'connected');
334
+ } catch (err) {
335
+ log.error('the installation could not be confirmed:', { detail: err instanceof Error ? err.message : String(err) });
336
+ finish(res, outcomeOf(err));
337
+ }
338
+ });
339
+
340
+ router.get('/setup/github-app/repositories', requireAdmin, async (_req, res) => {
341
+ if (!connection.credentials() || !connection.installationId()) {
342
+ res.status(409).json({ error: 'Connect GitHub first.' });
343
+ return;
344
+ }
345
+ try {
346
+ res.json(await connection.repositories());
347
+ } catch (err) {
348
+ log.error('the repositories could not be listed:', { detail: err instanceof Error ? err.message : String(err) });
349
+ const unreachable = err instanceof GitHubAppError && err.kind === 'unreachable';
350
+ res.status(502).json({
351
+ error: unreachable
352
+ ? 'GitHub could not be reached. Try again shortly.'
353
+ : 'GitHub would not list the repositories. The app may have been uninstalled: connect it again.',
354
+ });
355
+ }
356
+ });
357
+
358
+ return router;
359
+ }
@@ -0,0 +1,19 @@
1
+ export {
2
+ GitHubAppClient,
3
+ GitHubAppError,
4
+ appJwt,
5
+ normalizePrivateKey,
6
+ type GitHubAppCredentials,
7
+ type GitHubFailureKind,
8
+ type Installation,
9
+ type InstallationRepository,
10
+ type InstallationToken,
11
+ type RegisteredApp,
12
+ } from './github-app.client.js';
13
+ export { GitHubAppConnection, repositoriesAsSetting, type GitHubAppConnectionOptions } from './github-app.connection.js';
14
+ export {
15
+ createGitHubAppRoutes,
16
+ githubAppCallbackUrl,
17
+ githubAppManifest,
18
+ type GitHubAppRoutesDeps,
19
+ } from './github-app.routes.js';
@@ -67,6 +67,16 @@ describe('assertValidRelativePath', () => {
67
67
  ['nested parent', 'a/../b'],
68
68
  ['current segment', 'a/./b'],
69
69
  ['null byte', 'a\x00b'],
70
+ // A line break is not merely an unwritable name: git's `cat-file --batch`
71
+ // reads one `<ref>:<path>` object name per LINE, so a path carrying one
72
+ // is two specs and git answers about the first — under the gates that were
73
+ // asked about the whole string. Every other control character goes with
74
+ // them, matching what the shared `validateFilename` has always refused.
75
+ ['line feed', 'Docs/secret.md\nzzz'],
76
+ ['carriage return', 'Docs/secret.md\rzzz'],
77
+ ['tab', 'Docs/a\tb.md'],
78
+ ['escape', 'Docs/a\u001Bb.md'],
79
+ ['delete', 'Docs/a\u007Fb.md'],
70
80
  ['leading dash', '-afile'],
71
81
  ])('rejects %s (%s)', (_label, p) => {
72
82
  expect(() => assertValidRelativePath(p)).toThrow(WorkflowValidationError);
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeRepositoryAddress, sameRepository } from '../remote-url.js';
3
+
4
+ /**
5
+ * The comparison decides whether a working copy is kept or deleted, so what
6
+ * it must never do is call the SAME repository a different one.
7
+ */
8
+ describe('sameRepository', () => {
9
+ it.each([
10
+ ['https://github.com/org/repo', 'https://github.com/org/repo.git'],
11
+ ['https://github.com/org/repo', 'https://github.com/org/repo/'],
12
+ ['https://github.com/org/repo.git/', 'https://github.com/org/repo'],
13
+ ['https://GitHub.com/org/repo', 'https://github.com/org/repo'],
14
+ ['HTTPS://github.com/org/repo', 'https://github.com/org/repo'],
15
+ ['https://x-access-token:secret@github.com/org/repo', 'https://github.com/org/repo'],
16
+ [' https://github.com/org/repo ', 'https://github.com/org/repo'],
17
+ ['/srv/git/kb.git', '/srv/git/kb'],
18
+ ['C:\\git\\kb.git\\', 'C:\\git\\kb'],
19
+ ])('%s is %s', (a, b) => {
20
+ expect(sameRepository(a, b)).toBe(true);
21
+ });
22
+
23
+ it.each([
24
+ ['https://github.com/org/repo', 'https://github.com/org/other'],
25
+ ['https://github.com/org/repo', 'https://github.com/other/repo'],
26
+ ['https://github.com/org/repo', 'https://gitlab.com/org/repo'],
27
+ ['https://github.com/org/repo', 'https://github.com/org/repo-kb'],
28
+ ['https://github.com/org/Repo', 'https://github.com/org/repo'],
29
+ ])('%s is not %s', (a, b) => {
30
+ expect(sameRepository(a, b)).toBe(false);
31
+ });
32
+
33
+ it('never carries a credential into the normalized form', () => {
34
+ expect(normalizeRepositoryAddress('https://user:token@github.com/org/repo.git')).toBe(
35
+ 'https://github.com/org/repo',
36
+ );
37
+ });
38
+ });
@@ -4,6 +4,7 @@ import {
4
4
  assertKbDirNameFree,
5
5
  assertRepoRootNameFree,
6
6
  assertRepoRootNameFreeArgs,
7
+ hasControlCharacter,
7
8
  isInsideRepo,
8
9
  normalizePathArgs,
9
10
  normalizeWorkspacePath,
@@ -47,6 +48,14 @@ describe('isInsideRepo', () => {
47
48
  expect(isInsideRepo('knowledge-base/KnowledgeBase/a\\b.md', KB)).toBe(false);
48
49
  });
49
50
 
51
+ it('refuses a control character anywhere: git reads a line break in a path as a separator', () => {
52
+ expect(isInsideRepo('knowledge-base/Docs/secret.md\nzzz', KB)).toBe(false);
53
+ expect(isInsideRepo('knowledge-base/Docs/secret.md\rzzz', KB)).toBe(false);
54
+ expect(isInsideRepo('knowledge-base/Docs/a\u0000b.md', KB)).toBe(false);
55
+ expect(isInsideRepo('knowledge-base/Docs/a\u001Bb.md', KB)).toBe(false);
56
+ expect(isInsideRepo('knowledge-base/Docs/a\u007Fb.md', KB)).toBe(false);
57
+ });
58
+
50
59
  it('tolerates a trailing slash on a directory path', () => {
51
60
  expect(isInsideRepo('knowledge-base/', KB)).toBe(true);
52
61
  expect(isInsideRepo('knowledge-base/KnowledgeBase/Projects/', KB)).toBe(true);
@@ -113,6 +122,120 @@ describe('assertInsideRepo', () => {
113
122
  });
114
123
  });
115
124
 
125
+ describe('a line break in a path — one name to the gates, two object names to git', () => {
126
+ // `git cat-file --batch` reads one `<ref>:<path>` spec per LINE. A path
127
+ // carrying a `\n` therefore means TWO things at once: the read gate, the
128
+ // download gate and the not-a-directory check are answered about the whole
129
+ // string — a name that exists nowhere, so the real file's own frontmatter
130
+ // rules are never the ones consulted — while git answers about the part
131
+ // before the break and hands over its bytes. In the access resolver's batch
132
+ // read the extra answer also shifts every result after it, so one directory
133
+ // is judged by another's `access.md`.
134
+ //
135
+ // Which is why the refusal is HERE, at the normaliser every accepted path
136
+ // passes through, and not at the route that happened to surface it: the next
137
+ // caller to build a git spec out of a path will not remember this.
138
+ const INJECTED = 'knowledge-base/Docs/secret.md\nzzz';
139
+
140
+ it('is refused by the normaliser, with a message that says what is wrong', () => {
141
+ let err: unknown;
142
+ try {
143
+ normalizeWorkspacePath(INJECTED, KB);
144
+ } catch (e) {
145
+ err = e;
146
+ }
147
+ expect(err).toBeInstanceOf(WorkflowValidationError);
148
+ const e = err as WorkflowValidationError;
149
+ expect(e.status).toBe(400);
150
+ expect(e.message).toMatch(/control character/);
151
+ // Never "use this instead": there is no corrected spelling of a name with
152
+ // an invisible byte in it.
153
+ expect(e.message).not.toMatch(/Use "/);
154
+ // The path is named, ESCAPED — `sanitizedPath`, the same spelling every
155
+ // other refusal uses — so the character that made the path invalid cannot
156
+ // forge a second line of the refusal either.
157
+ expect(e.payload).toMatchObject({
158
+ kind: 'path-control-character',
159
+ path: 'knowledge-base/Docs/secret.md\\nzzz',
160
+ });
161
+ expect(e.message).toContain('"knowledge-base/Docs/secret.md\\nzzz"');
162
+ expect(e.message).not.toContain('\n');
163
+ });
164
+
165
+ it('is refused the same way unprefixed, which is the form a route receives', () => {
166
+ expect(() => normalizeWorkspacePath('Docs/secret.md\nzzz', KB)).toThrow(/control character/);
167
+ });
168
+
169
+ it('is refused by the check AFTER the normaliser too', () => {
170
+ expect(() => assertInsideRepo(INJECTED, KB)).toThrow(/control character/);
171
+ });
172
+
173
+ it('refuses every control character, not only the line breaks', () => {
174
+ for (const ch of ['\u0000', '\u0001', '\t', '\n', '\u000B', '\r', '\u001F', '\u007F']) {
175
+ expect(() => normalizeWorkspacePath(`knowledge-base/Docs/a${ch}b.md`, KB), ch).toThrow(
176
+ WorkflowValidationError,
177
+ );
178
+ }
179
+ });
180
+
181
+ it('refuses a CHECKOUT FOLDER NAME carrying one, which the normaliser would otherwise emit', () => {
182
+ // The name is prefixed onto the result rather than read from it, so a
183
+ // clean path joined to `kb\nname` would come out carrying the break: the
184
+ // normaliser would emit exactly what it refuses from a caller, from inputs
185
+ // that were each individually fine.
186
+ let err: unknown;
187
+ try {
188
+ normalizeWorkspacePath('Docs/report.md', 'kb\nname');
189
+ } catch (e) {
190
+ err = e;
191
+ }
192
+ expect(err).toBeInstanceOf(WorkflowValidationError);
193
+ const e = err as WorkflowValidationError;
194
+ // Named as the CONFIGURATION error it is: an operator told their own path
195
+ // was invalid would have nothing to fix.
196
+ expect(e.message).toMatch(/checkout folder name/);
197
+ expect(e.message).toMatch(/KB_DIR_NAME/);
198
+ expect(e.message).toContain('"kb\\nname"');
199
+ expect(e.payload).toMatchObject({ kind: 'kb-dir-name-control-character', kbDirName: 'kb\\nname' });
200
+ // The guarantee the fence is for: no return value of this function carries
201
+ // a control character, whatever it was given.
202
+ for (const dir of ['kb\nname', 'kb\rname', 'kb\u0000name']) {
203
+ for (const p of ['Docs/report.md', `${dir}/Docs/report.md`, 'x']) {
204
+ let out: string | null = null;
205
+ try {
206
+ out = normalizeWorkspacePath(p, dir);
207
+ } catch {
208
+ out = null;
209
+ }
210
+ if (out !== null) expect(hasControlCharacter(out), `${dir} + ${p}`).toBe(false);
211
+ }
212
+ }
213
+ });
214
+
215
+ it('exposes the rule as a predicate, so the places that SET a name share it', () => {
216
+ expect(hasControlCharacter('knowledge-base')).toBe(false);
217
+ expect(hasControlCharacter('kb\nname')).toBe(true);
218
+ expect(hasControlCharacter('kb\u007Fname')).toBe(true);
219
+ // U+2028 is not in the range: nothing to git, and a name carrying one is
220
+ // creatable today — refusing it would make an existing file unreadable.
221
+ expect(hasControlCharacter('kb\u2028name')).toBe(false);
222
+ });
223
+
224
+ it('leaves ordinary names alone — the rule costs no real path', () => {
225
+ expect(normalizeWorkspacePath('knowledge-base/Docs/[Approved] Handbook_Order archive.docx', KB)).toBe(
226
+ 'knowledge-base/Docs/[Approved] Handbook_Order archive.docx',
227
+ );
228
+ expect(normalizeWorkspacePath('knowledge-base/Docs/Ünïcödé — naïve.md', KB)).toBe(
229
+ 'knowledge-base/Docs/Ünïcödé — naïve.md',
230
+ );
231
+ });
232
+
233
+ it('refuses it through normalizePathArgs, which is where the MCP tools meet the rule', () => {
234
+ expect(() => normalizePathArgs({ path: INJECTED }, KB)).toThrow(/control character/);
235
+ expect(() => normalizePathArgs({ files: [{ path: INJECTED }] }, KB)).toThrow(/control character/);
236
+ });
237
+ });
238
+
116
239
  describe('normalizeWorkspacePath — THE normaliser', () => {
117
240
  it('leaves a path that is already inside the repository alone', () => {
118
241
  expect(normalizeWorkspacePath('knowledge-base/KnowledgeBase/Foo.md', KB)).toBe('knowledge-base/KnowledgeBase/Foo.md');
@@ -49,7 +49,20 @@ export function assertValidBranchName(name: string): void {
49
49
  export function assertValidRelativePath(relativePath: string): void {
50
50
  if (!relativePath) throw new WorkflowValidationError('path is required');
51
51
  if (relativePath.length > 1024) throw new WorkflowValidationError('path too long');
52
- if (relativePath.includes('\0')) throw new WorkflowValidationError('path contains NUL');
52
+ // NUL, every other C0 control and DEL. A NUL is named on its own because it
53
+ // terminates a C string and git's `-z` output; the rest are refused with it
54
+ // because a LINE BREAK in a path is a separator in git's line-oriented stdin
55
+ // protocols — `cat-file --batch` reads one `<ref>:<path>` per line, so
56
+ // `Docs/secret.md\nzzz` is two object names while every gate upstream was
57
+ // asked about one string. See `CONTROL_CHARACTERS` in `repo-path.ts`, which
58
+ // refuses the same range at the normaliser; this is the same rule at the
59
+ // check every path handed to git as a pathspec passes through.
60
+ // eslint-disable-next-line no-control-regex
61
+ if (/[\x00-\x1F\x7F]/.test(relativePath)) {
62
+ throw new WorkflowValidationError(
63
+ relativePath.includes('\0') ? 'path contains NUL' : 'path contains a control character',
64
+ );
65
+ }
53
66
  if (relativePath.includes('\\')) {
54
67
  throw new WorkflowValidationError('path must use forward slashes only');
55
68
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Whether two repository addresses name the same repository.
3
+ *
4
+ * The answer decides whether a working copy on disk is KEPT or DELETED, so it
5
+ * errs towards "same": only differences that cannot change which repository
6
+ * is meant are ignored, and they are ignored on both sides.
7
+ *
8
+ * - a trailing slash, and a `.git` suffix — hosts serve both spellings;
9
+ * - the letter case of the scheme and the host — DNS does not distinguish;
10
+ * - userinfo (`user:token@`) — who asks does not change what is asked for,
11
+ * and a credential must never decide, or appear in, a comparison.
12
+ *
13
+ * The PATH keeps its case: hosts that fold it and hosts that do not both
14
+ * exist, and treating `Org/Repo` and `org/repo` as one would keep a clone of
15
+ * the wrong repository on a host that tells them apart — but deleting a clone
16
+ * of the right one is the worse error, and an operator who only changed the
17
+ * case gets a re-clone, not a loss: the work on that clone was pushed to the
18
+ * same place.
19
+ */
20
+ export function normalizeRepositoryAddress(address: string): string {
21
+ let a = address.trim();
22
+ const url = /^([a-z][a-z0-9+.-]*):\/\/([^/]*)(\/.*)?$/i.exec(a);
23
+ if (url) {
24
+ const host = url[2]!.replace(/^[^@]*@/, '').toLowerCase();
25
+ a = `${url[1]!.toLowerCase()}://${host}${url[3] ?? ''}`;
26
+ }
27
+ return a
28
+ .replace(/[\\/]+$/, '')
29
+ .replace(/\.git$/i, '')
30
+ .replace(/[\\/]+$/, '');
31
+ }
32
+
33
+ export function sameRepository(a: string, b: string): boolean {
34
+ return normalizeRepositoryAddress(a) === normalizeRepositoryAddress(b);
35
+ }