@bevel-software/platform-core-backend 0.4.1 → 0.5.1

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 (305) hide show
  1. package/THIRD-PARTY-NOTICES.md +6118 -0
  2. package/dist/core/core-ports.d.ts +15 -0
  3. package/dist/core/core-ports.d.ts.map +1 -1
  4. package/dist/core/core-ports.js.map +1 -1
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +40 -6
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +17 -1
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +153 -29
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core-config.d.ts +70 -21
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +134 -58
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +6 -0
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +74 -5
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +116 -15
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access-declarations.d.ts +93 -0
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  24. package/dist/modules/access/access-declarations.js +168 -0
  25. package/dist/modules/access/access-declarations.js.map +1 -0
  26. package/dist/modules/access/access-mutation.service.js +4 -4
  27. package/dist/modules/access/access-mutation.service.js.map +1 -1
  28. package/dist/modules/access/access-splice.d.ts +16 -1
  29. package/dist/modules/access/access-splice.d.ts.map +1 -1
  30. package/dist/modules/access/access-splice.js +40 -21
  31. package/dist/modules/access/access-splice.js.map +1 -1
  32. package/dist/modules/access/access.routes.d.ts.map +1 -1
  33. package/dist/modules/access/access.routes.js +51 -0
  34. package/dist/modules/access/access.routes.js.map +1 -1
  35. package/dist/modules/access/creator-access.d.ts +8 -0
  36. package/dist/modules/access/creator-access.d.ts.map +1 -1
  37. package/dist/modules/access/creator-access.js +21 -2
  38. package/dist/modules/access/creator-access.js.map +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  40. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  41. package/dist/modules/admin/admin-access.service.js +3 -2
  42. package/dist/modules/admin/admin-access.service.js.map +1 -1
  43. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  44. package/dist/modules/auth/auth.routes.js +50 -1
  45. package/dist/modules/auth/auth.routes.js.map +1 -1
  46. package/dist/modules/auth/auth.service.d.ts +39 -10
  47. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  48. package/dist/modules/auth/auth.service.js +38 -16
  49. package/dist/modules/auth/auth.service.js.map +1 -1
  50. package/dist/modules/database/core-schema.d.ts +124 -0
  51. package/dist/modules/database/core-schema.d.ts.map +1 -1
  52. package/dist/modules/database/core-schema.js +45 -0
  53. package/dist/modules/database/core-schema.js.map +1 -1
  54. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  55. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  56. package/dist/modules/groups/group-provision.service.js +288 -0
  57. package/dist/modules/groups/group-provision.service.js.map +1 -0
  58. package/dist/modules/groups/groups.contract.d.ts +106 -0
  59. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  60. package/dist/modules/groups/groups.contract.js +36 -0
  61. package/dist/modules/groups/groups.contract.js.map +1 -0
  62. package/dist/modules/groups/groups.routes.d.ts +42 -0
  63. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  64. package/dist/modules/groups/groups.routes.js +379 -0
  65. package/dist/modules/groups/groups.routes.js.map +1 -0
  66. package/dist/modules/groups/groups.service.d.ts +60 -0
  67. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  68. package/dist/modules/groups/groups.service.js +172 -0
  69. package/dist/modules/groups/groups.service.js.map +1 -0
  70. package/dist/modules/groups/index.d.ts +7 -0
  71. package/dist/modules/groups/index.d.ts.map +1 -0
  72. package/dist/modules/groups/index.js +6 -0
  73. package/dist/modules/groups/index.js.map +1 -0
  74. package/dist/modules/groups/join-proposals.d.ts +53 -0
  75. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  76. package/dist/modules/groups/join-proposals.js +67 -0
  77. package/dist/modules/groups/join-proposals.js.map +1 -0
  78. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  79. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  80. package/dist/modules/groups/join-requests.service.js +135 -0
  81. package/dist/modules/groups/join-requests.service.js.map +1 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  83. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  85. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  86. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  87. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  88. package/dist/modules/settings/deployment-settings.service.js +368 -0
  89. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  90. package/dist/modules/settings/setup.routes.d.ts +49 -0
  91. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  92. package/dist/modules/settings/setup.routes.js +292 -0
  93. package/dist/modules/settings/setup.routes.js.map +1 -0
  94. package/dist/modules/skills/index.d.ts +2 -1
  95. package/dist/modules/skills/index.d.ts.map +1 -1
  96. package/dist/modules/skills/index.js +1 -0
  97. package/dist/modules/skills/index.js.map +1 -1
  98. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  99. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  100. package/dist/modules/skills/pending-skills.service.js +147 -0
  101. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  102. package/dist/modules/skills/skills.contract.d.ts +32 -3
  103. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  104. package/dist/modules/skills/skills.contract.js +1 -1
  105. package/dist/modules/skills/skills.routes.d.ts +3 -2
  106. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  107. package/dist/modules/skills/skills.routes.js +18 -1
  108. package/dist/modules/skills/skills.routes.js.map +1 -1
  109. package/dist/modules/skills/skills.service.d.ts +22 -0
  110. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  111. package/dist/modules/skills/skills.service.js +24 -12
  112. package/dist/modules/skills/skills.service.js.map +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts +1 -1
  114. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  115. package/dist/modules/tool-manuals/index.js.map +1 -1
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  117. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  120. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  122. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  125. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  126. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  129. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  132. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  133. package/dist/modules/workflow/event-bus.d.ts +11 -0
  134. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  135. package/dist/modules/workflow/event-bus.js +22 -0
  136. package/dist/modules/workflow/event-bus.js.map +1 -1
  137. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  138. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  139. package/dist/modules/workflow/file-lock.service.js +16 -0
  140. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  142. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  143. package/dist/modules/workflow/git/branch-name.js +1 -1
  144. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  145. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  146. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  147. package/dist/modules/workflow/git/git.service.js +144 -52
  148. package/dist/modules/workflow/git/git.service.js.map +1 -1
  149. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  150. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  151. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  152. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  155. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  157. package/dist/modules/workflow/workflow.routes.js +95 -14
  158. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  159. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  160. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  161. package/dist/modules/workflow/workflow.service.js +381 -108
  162. package/dist/modules/workflow/workflow.service.js.map +1 -1
  163. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  164. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  165. package/dist/modules/workspace/bevel-ignore.js +5 -1
  166. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  167. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  168. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  169. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  170. package/dist/modules/workspace/kb-seed.service.js +228 -51
  171. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  173. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.routes.js +51 -18
  175. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  176. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  177. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.service.js +36 -9
  179. package/dist/modules/workspace/workspace.service.js.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.tools.js +8 -3
  182. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  183. package/dist/shared/kb-layout.test.js +2 -2
  184. package/dist/test-setup.d.ts +2 -0
  185. package/dist/test-setup.d.ts.map +1 -0
  186. package/dist/test-setup.js +18 -0
  187. package/dist/test-setup.js.map +1 -0
  188. package/kb-template/.bevelignore +6 -2
  189. package/kb-template/AGENTS.md +272 -0
  190. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  191. package/kb-template/access.md +36 -36
  192. package/migrations/0002_user_onboarding_done.sql +1 -0
  193. package/migrations/0003_deployment_settings.sql +9 -0
  194. package/migrations/meta/0001_snapshot.json +1486 -0
  195. package/migrations/meta/0002_snapshot.json +1493 -0
  196. package/migrations/meta/0003_snapshot.json +1552 -0
  197. package/migrations/meta/_journal.json +15 -1
  198. package/package.json +5 -4
  199. package/src/__tests__/branch-model.test.ts +79 -0
  200. package/src/__tests__/core-config.admin.test.ts +114 -0
  201. package/src/__tests__/database-url.test.ts +70 -0
  202. package/src/core/core-ports.ts +15 -0
  203. package/src/core/create-core-server.ts +55 -6
  204. package/src/core/create-core-services.ts +201 -36
  205. package/src/core-config.ts +139 -59
  206. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  207. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  208. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  209. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  210. package/src/modules/access/access-control.interface.ts +11 -0
  211. package/src/modules/access/access-control.service.ts +113 -15
  212. package/src/modules/access/access-declarations.ts +251 -0
  213. package/src/modules/access/access-mutation.service.ts +4 -4
  214. package/src/modules/access/access-splice.ts +64 -21
  215. package/src/modules/access/access.routes.ts +61 -0
  216. package/src/modules/access/creator-access.ts +22 -2
  217. package/src/modules/admin/admin-access.service.ts +4 -3
  218. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  219. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  220. package/src/modules/auth/auth.routes.ts +50 -1
  221. package/src/modules/auth/auth.service.ts +55 -17
  222. package/src/modules/database/core-schema.ts +46 -0
  223. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  224. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  225. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  226. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  227. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  228. package/src/modules/groups/group-provision.service.ts +328 -0
  229. package/src/modules/groups/groups.contract.ts +107 -0
  230. package/src/modules/groups/groups.routes.ts +433 -0
  231. package/src/modules/groups/groups.service.ts +193 -0
  232. package/src/modules/groups/index.ts +17 -0
  233. package/src/modules/groups/join-proposals.ts +114 -0
  234. package/src/modules/groups/join-requests.service.ts +185 -0
  235. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  236. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  237. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  238. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  239. package/src/modules/settings/deployment-settings.service.ts +419 -0
  240. package/src/modules/settings/setup.routes.ts +317 -0
  241. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  242. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  243. package/src/modules/skills/index.ts +3 -0
  244. package/src/modules/skills/pending-skills.service.ts +168 -0
  245. package/src/modules/skills/skills.contract.ts +34 -3
  246. package/src/modules/skills/skills.routes.ts +23 -2
  247. package/src/modules/skills/skills.service.ts +24 -16
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  251. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  252. package/src/modules/tool-manuals/index.ts +2 -0
  253. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  254. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  255. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  256. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  257. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  259. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  260. package/src/modules/workflow/event-bus.ts +25 -0
  261. package/src/modules/workflow/file-lock.service.ts +17 -0
  262. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  263. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  264. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  265. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  266. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  267. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  268. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  269. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  270. package/src/modules/workflow/git/branch-name.ts +5 -1
  271. package/src/modules/workflow/git/git.service.ts +159 -62
  272. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  273. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  274. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  275. package/src/modules/workflow/workflow.routes.ts +91 -13
  276. package/src/modules/workflow/workflow.service.ts +353 -13
  277. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  278. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  279. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  280. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  281. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  283. package/src/modules/workspace/bevel-ignore.ts +66 -62
  284. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  285. package/src/modules/workspace/kb-seed.service.ts +247 -53
  286. package/src/modules/workspace/workspace.routes.ts +54 -18
  287. package/src/modules/workspace/workspace.service.ts +45 -7
  288. package/src/modules/workspace/workspace.tools.ts +922 -917
  289. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  290. package/src/shared/__tests__/join-request.test.ts +96 -0
  291. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  292. package/src/shared/kb-layout.test.ts +2 -2
  293. package/src/test-setup.ts +18 -0
  294. package/kb-template/Agents/README.md +0 -38
  295. package/kb-template/CLAUDE.md +0 -419
  296. package/kb-template/Data/.gitkeep +0 -0
  297. package/kb-template/Data/README.md +0 -39
  298. package/kb-template/KnowledgeBase/README.md +0 -37
  299. package/kb-template/Pipelines/.gitkeep +0 -0
  300. package/kb-template/Pipelines/README.md +0 -38
  301. package/kb-template/Skills/.gitkeep +0 -0
  302. package/kb-template/Skills/README.md +0 -32
  303. package/kb-template/Tools/.gitkeep +0 -0
  304. package/kb-template/Tools/README.md +0 -42
  305. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -14,6 +14,7 @@ describe('computeViewerCanCancel', () => {
14
14
  authorId: AUTHOR_HASH,
15
15
  viewerEmail: undefined,
16
16
  viewerCanBypassMerge: false,
17
+ viewerWritesAllFiles: false,
17
18
  }),
18
19
  ).toBe(false);
19
20
  });
@@ -25,6 +26,7 @@ describe('computeViewerCanCancel', () => {
25
26
  authorId: AUTHOR_HASH,
26
27
  viewerEmail: EMAIL,
27
28
  viewerCanBypassMerge: false,
29
+ viewerWritesAllFiles: false,
28
30
  }),
29
31
  ).toBe(true);
30
32
  });
@@ -36,6 +38,7 @@ describe('computeViewerCanCancel', () => {
36
38
  authorId: OTHER_AUTHOR_HASH,
37
39
  viewerEmail: EMAIL,
38
40
  viewerCanBypassMerge: true,
41
+ viewerWritesAllFiles: false,
39
42
  }),
40
43
  ).toBe(true);
41
44
  });
@@ -47,6 +50,7 @@ describe('computeViewerCanCancel', () => {
47
50
  authorId: AUTHOR_HASH,
48
51
  viewerEmail: EMAIL,
49
52
  viewerCanBypassMerge: true,
53
+ viewerWritesAllFiles: false,
50
54
  }),
51
55
  ).toBe(true);
52
56
  });
@@ -58,6 +62,7 @@ describe('computeViewerCanCancel', () => {
58
62
  authorId: AUTHOR_HASH,
59
63
  viewerEmail: EMAIL,
60
64
  viewerCanBypassMerge: false,
65
+ viewerWritesAllFiles: false,
61
66
  }),
62
67
  ).toBe(false);
63
68
  });
@@ -69,6 +74,7 @@ describe('computeViewerCanCancel', () => {
69
74
  authorId: OTHER_AUTHOR_HASH,
70
75
  viewerEmail: EMAIL,
71
76
  viewerCanBypassMerge: true,
77
+ viewerWritesAllFiles: false,
72
78
  }),
73
79
  ).toBe(false);
74
80
  });
@@ -80,6 +86,7 @@ describe('computeViewerCanCancel', () => {
80
86
  authorId: undefined,
81
87
  viewerEmail: EMAIL,
82
88
  viewerCanBypassMerge: false,
89
+ viewerWritesAllFiles: false,
83
90
  }),
84
91
  ).toBe(false);
85
92
  });
@@ -95,7 +102,44 @@ describe('computeViewerCanCancel', () => {
95
102
  authorId: AUTHOR_HASH,
96
103
  viewerEmail: ' Juan@Bevel.Software ',
97
104
  viewerCanBypassMerge: false,
105
+ viewerWritesAllFiles: false,
98
106
  }),
99
107
  ).toBe(true);
100
108
  });
109
+
110
+ it('returns true for a non-author, non-admin who writes EVERY changed file (the reject route’s third grant)', () => {
111
+ expect(
112
+ computeViewerCanCancel({
113
+ state: 'open',
114
+ authorId: OTHER_AUTHOR_HASH,
115
+ viewerEmail: EMAIL,
116
+ viewerCanBypassMerge: false,
117
+ viewerWritesAllFiles: true,
118
+ }),
119
+ ).toBe(true);
120
+ });
121
+
122
+ it('fails closed for an anonymous viewer even with the owner grant', () => {
123
+ expect(
124
+ computeViewerCanCancel({
125
+ state: 'open',
126
+ authorId: OTHER_AUTHOR_HASH,
127
+ viewerEmail: undefined,
128
+ viewerCanBypassMerge: false,
129
+ viewerWritesAllFiles: true,
130
+ }),
131
+ ).toBe(false);
132
+ });
133
+
134
+ it('terminal states refuse the owner grant too', () => {
135
+ expect(
136
+ computeViewerCanCancel({
137
+ state: 'merged',
138
+ authorId: OTHER_AUTHOR_HASH,
139
+ viewerEmail: EMAIL,
140
+ viewerCanBypassMerge: false,
141
+ viewerWritesAllFiles: true,
142
+ }),
143
+ ).toBe(false);
144
+ });
101
145
  });
@@ -7,7 +7,11 @@ export { PROTECTED_BRANCHES, isProtectedBranch } from '@bevel-software/platform-
7
7
  // picker's `canDelete` rule and the backend authorisation check on
8
8
  // `deleteBranch` must agree on every edge case (uppercase email, dotted
9
9
  // localpart, etc.).
10
- export { branchAuthorLocalpart, isBranchAuthoredBy } from '@bevel-software/platform-shared';
10
+ export {
11
+ branchAuthorLocalpart,
12
+ isBranchAuthoredBy,
13
+ isOwnSuggestionsBranch,
14
+ } from '@bevel-software/platform-shared';
11
15
 
12
16
  const VALID_BRANCH_REGEX = /^[A-Za-z0-9][A-Za-z0-9/_\-.]*$/;
13
17
 
@@ -22,6 +22,7 @@ import {
22
22
  assertValidBranchName,
23
23
  assertValidRelativePath,
24
24
  isBranchAuthoredBy,
25
+ isOwnSuggestionsBranch,
25
26
  isProtectedBranch,
26
27
  PROTECTED_BRANCHES,
27
28
  } from './branch-name.js';
@@ -607,7 +608,7 @@ export class GitService implements IGitService {
607
608
  workspaceId: string,
608
609
  name: string,
609
610
  user: AuthUser,
610
- opts: { onlyIfNoRemote?: boolean } = {},
611
+ opts: { onlyIfNoRemote?: boolean; systemCleanup?: boolean } = {},
611
612
  ): Promise<void> {
612
613
  assertValidBranchName(name);
613
614
  if (isProtectedBranch(name)) {
@@ -622,7 +623,12 @@ export class GitService implements IGitService {
622
623
  );
623
624
  }
624
625
 
625
- if (opts.onlyIfNoRemote) {
626
+ if (opts.systemCleanup) {
627
+ // Internal post-merge retirement (never reachable from a route): the
628
+ // merge that emptied this branch was itself authorized, so no
629
+ // authorship check — the protected-branch and checked-out guards
630
+ // above still hold.
631
+ } else if (opts.onlyIfNoRemote) {
626
632
  // Legacy orphan-cleanup path (PR merged + remote head pruned). Skip
627
633
  // the authorship check — callers prune any orphan they encounter,
628
634
  // not just their own — but keep the "refuse if origin still has the
@@ -642,7 +648,13 @@ export class GitService implements IGitService {
642
648
  // that predicate is the existing source of truth for `Admin`-role
643
649
  // membership inside the access model — no separate `isAdmin()` plumbing
644
650
  // needed.
645
- const isAuthor = isBranchAuthoredBy(name, user.email);
651
+ // Two authorship conventions: `<localpart>/…` UI drafts, and the
652
+ // `suggestions/<who>-<id8>/…` namespace the propose flow files its
653
+ // branches under — a user resetting THEIR OWN suggestion bundle is
654
+ // deleting their own branch.
655
+ const isAuthor =
656
+ isBranchAuthoredBy(name, user.email) ||
657
+ isOwnSuggestionsBranch(name, { email: user.email, id: user.id });
646
658
  const isAdmin = this.accessControl
647
659
  ? await this.accessControl.canWrite(workspaceId, user.email, 'roles.yaml')
648
660
  : false;
@@ -1017,7 +1029,11 @@ export class GitService implements IGitService {
1017
1029
  });
1018
1030
  }
1019
1031
 
1020
- async push(workspaceId: string, user: AuthUser): Promise<void> {
1032
+ async push(
1033
+ workspaceId: string,
1034
+ user: AuthUser,
1035
+ opts?: { systemAuthorized?: boolean },
1036
+ ): Promise<void> {
1021
1037
  return this.mutex.run(workspaceId, async () => {
1022
1038
  const cwd = await this.repoDir(workspaceId);
1023
1039
  const branch = await this.currentBranch(cwd);
@@ -1030,7 +1046,14 @@ export class GitService implements IGitService {
1030
1046
  // PR. Gate against `origin/<branch>` — the published state — for
1031
1047
  // protected pushes; that's defence-in-depth against local rebases or
1032
1048
  // `commit-tree` shenanigans that might bypass the commit-time gate.
1033
- if (isProtectedBranch(branch)) {
1049
+ // `systemAuthorized` skips the gate for flows whose ENDPOINT is the
1050
+ // authorization — group provisioning commits an access.md into a
1051
+ // folder that does not exist at origin yet, which this gate can only
1052
+ // ever read as "write: Admin". The provisioning service has already
1053
+ // decided the write is legitimate (unused name, exclusive create);
1054
+ // gating it here again just refuses every non-admin the product
1055
+ // promised a group to.
1056
+ if (isProtectedBranch(branch) && !opts?.systemAuthorized) {
1034
1057
  const touched = await this.unpushedTouchedPaths(cwd);
1035
1058
  await this.assertCanWriteAtRef(
1036
1059
  workspaceId,
@@ -1608,60 +1631,6 @@ export class GitService implements IGitService {
1608
1631
  });
1609
1632
  }
1610
1633
 
1611
- async revertCommit(
1612
- workspaceId: string,
1613
- user: AuthUser,
1614
- sha: string,
1615
- ): Promise<CommitAttribution> {
1616
- if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
1617
- throw new WorkflowValidationError('invalid commit sha');
1618
- }
1619
- assertValidAuthor(user);
1620
- return this.mutex.run(workspaceId, async () => {
1621
- const cwd = await this.repoDir(workspaceId);
1622
- const branch = await this.currentBranch(cwd);
1623
-
1624
- // Revert creates a new commit reversing `sha`. Same model as commit:
1625
- // gate only on protected branches; reverts on feature/draft branches
1626
- // are free (they produce a commit that would still need to merge via
1627
- // PR to affect canonical state).
1628
- if (isProtectedBranch(branch)) {
1629
- const { stdout: diffOut } = await this.git(cwd, [
1630
- 'diff-tree', '--no-commit-id', '--name-only', '-r', sha,
1631
- ]);
1632
- const touched = diffOut.split('\n').map((s) => s.trim()).filter(Boolean);
1633
- await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
1634
- }
1635
-
1636
- await this.git(cwd, [
1637
- 'revert',
1638
- '--no-edit',
1639
- `--author=${user.name} <${user.email}>`,
1640
- sha,
1641
- ]);
1642
-
1643
- const { stdout } = await this.git(cwd, [
1644
- 'log',
1645
- '-1',
1646
- '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
1647
- ]);
1648
- const [headSha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
1649
-
1650
- // A revert may have touched roles.yaml or access.md — drop cached
1651
- // access state so the next gate read reflects the rolled-back tree.
1652
- // Mirrors the commit / pull paths.
1653
- this.accessControl?.invalidate(workspaceId);
1654
-
1655
- return {
1656
- sha: headSha?.trim() ?? '',
1657
- authorName: authorName ?? '',
1658
- authorEmail: authorEmail ?? '',
1659
- subject: subj ?? '',
1660
- committedAt: committedAt?.trim() ?? new Date().toISOString(),
1661
- };
1662
- });
1663
- }
1664
-
1665
1634
  async logForFile(
1666
1635
  workspaceId: string,
1667
1636
  relativePath: string,
@@ -1726,6 +1695,44 @@ export class GitService implements IGitService {
1726
1695
  });
1727
1696
  }
1728
1697
 
1698
+ /**
1699
+ * Full file contents on both sides of one commit: `baseline` is the file at
1700
+ * `<sha>^` (the parent), `current` at `<sha>`. Null means the file is absent
1701
+ * at that ref — added files have `baseline: null`, deleted files
1702
+ * `current: null`. A root commit (no parent) also yields `baseline: null`.
1703
+ * Feeds the rendered-markdown history view, which needs before/after text
1704
+ * rather than the patch `diffFileAtCommit` returns.
1705
+ */
1706
+ async fileContentsAtCommit(
1707
+ workspaceId: string,
1708
+ relativePath: string,
1709
+ sha: string,
1710
+ ): Promise<{ baseline: string | null; current: string | null }> {
1711
+ assertValidRelativePath(relativePath);
1712
+ if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
1713
+ throw new WorkflowValidationError('invalid commit sha');
1714
+ }
1715
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1716
+ return this.mutex.run(workspaceId, async () => {
1717
+ const current = await this.readFileAtRef(workspaceId, sha, repoRelativePath);
1718
+ let baseline: string | null;
1719
+ try {
1720
+ baseline = await this.readFileAtRef(workspaceId, `${sha}^`, repoRelativePath);
1721
+ } catch (err) {
1722
+ // `readFileAtRef` maps "path absent at ref" to null but propagates an
1723
+ // unresolvable ref. `<sha>^` on a root commit is exactly that (git
1724
+ // reports it as "invalid object name '<sha>^'", or "unknown/bad
1725
+ // revision" in other codepaths), and it legitimately means "no parent
1726
+ // side" — fold it into null. Anything else stays fatal.
1727
+ const stderr =
1728
+ (err as { stderr?: string }).stderr ?? (err instanceof Error ? err.message : String(err));
1729
+ if (/invalid object name|unknown revision|bad revision/i.test(stderr)) baseline = null;
1730
+ else throw err;
1731
+ }
1732
+ return { baseline, current };
1733
+ });
1734
+ }
1735
+
1729
1736
  async diffFileBetweenBranches(
1730
1737
  workspaceId: string,
1731
1738
  relativePath: string,
@@ -1859,8 +1866,20 @@ export class GitService implements IGitService {
1859
1866
  this.git(cwd, ['diff', '-M', '-z', '--name-status', range]),
1860
1867
  this.git(cwd, ['diff', '-M', '-z', '--numstat', range]),
1861
1868
  ]);
1862
- const statuses = parseNameStatusZ(nameStatusOut);
1863
- const counts = parseNumstatZ(numstatOut);
1869
+ let statuses = parseNameStatusZ(nameStatusOut);
1870
+ let counts = parseNumstatZ(numstatOut);
1871
+ // roles.yaml can NEVER change through a merge — `preserveBaseRolesYaml`
1872
+ // restores the base copy onto the source branch before every merge — so
1873
+ // listing it as "changed" claims something the merge will not do, and
1874
+ // (worse) makes its approval a requirement for a change that cannot
1875
+ // land. Filter it from the review surface entirely; the neutralisation
1876
+ // reads the raw refs itself and is unaffected. Both lists are filtered
1877
+ // IN STEP so the index-zip below stays aligned.
1878
+ if (statuses.some((s) => s.path === 'roles.yaml')) {
1879
+ const keep = statuses.map((s) => s.path !== 'roles.yaml');
1880
+ statuses = statuses.filter((_, i) => keep[i]);
1881
+ if (counts.length === keep.length) counts = counts.filter((_, i) => keep[i]);
1882
+ }
1864
1883
 
1865
1884
  // `--name-status` and `--numstat` enumerate the same files in the same
1866
1885
  // order (same `-M` over the same range), so we zip by index. If the two
@@ -1924,7 +1943,85 @@ export class GitService implements IGitService {
1924
1943
  const { stdout } = await this.git(cwd, [
1925
1944
  'diff', '-M', '--name-only', `${baseRef}...${headRef}`,
1926
1945
  ]);
1927
- return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1946
+ return (
1947
+ stdout
1948
+ .split('\n')
1949
+ .map((s) => s.trim())
1950
+ .filter(Boolean)
1951
+ // Same rule as `changedFilesForPr`: a roles.yaml change never
1952
+ // survives a merge, so it is not a touched path for routing or
1953
+ // summaries either.
1954
+ .filter((p) => p !== 'roles.yaml')
1955
+ );
1956
+ });
1957
+ }
1958
+
1959
+ /**
1960
+ * The merge-base commit of a change request's two branches (their origin
1961
+ * refs), or null when they share no history. This is the "before" the CR's
1962
+ * three-dot diff is read against — the ref a per-file revert restores from,
1963
+ * so the reverted file drops OUT of that same diff.
1964
+ */
1965
+ async mergeBaseForPr(
1966
+ workspaceId: string,
1967
+ baseBranch: string,
1968
+ headBranch: string,
1969
+ ): Promise<string | null> {
1970
+ assertValidBranchName(baseBranch);
1971
+ assertValidBranchName(headBranch);
1972
+ const cwd = await this.repoDir(workspaceId);
1973
+ await this.fetchPrRefs(cwd, baseBranch, headBranch);
1974
+ return this.mutex.run(workspaceId, async () => {
1975
+ const baseRef = await this.resolveBranchRef(cwd, baseBranch);
1976
+ const headRef = await this.resolveBranchRef(cwd, headBranch);
1977
+ try {
1978
+ const { stdout } = await this.git(cwd, ['merge-base', baseRef, headRef]);
1979
+ return stdout.trim() || null;
1980
+ } catch (err) {
1981
+ // Exit 1 is git's specific "no common ancestor" answer; anything else
1982
+ // is an infra failure that must not masquerade as it.
1983
+ if ((err as { exitCode?: number }).exitCode === 1) return null;
1984
+ throw err;
1985
+ }
1986
+ });
1987
+ }
1988
+
1989
+ /**
1990
+ * Restore one path in the working tree (and index) to its content at `ref`;
1991
+ * a path ABSENT at `ref` is deleted — the revert of an added file is its
1992
+ * removal. Byte-exact via git itself (`checkout <ref> -- <path>`), never a
1993
+ * read-as-string round-trip that would mangle binary files. The caller owns
1994
+ * committing the result (see `commitFile`).
1995
+ */
1996
+ async restorePathFromRef(
1997
+ workspaceId: string,
1998
+ ref: string,
1999
+ repoRelativePath: string,
2000
+ ): Promise<void> {
2001
+ assertValidRelativePath(repoRelativePath);
2002
+ return this.mutex.run(workspaceId, async () => {
2003
+ const cwd = await this.repoDir(workspaceId);
2004
+ let existsAtRef = true;
2005
+ try {
2006
+ await this.git(cwd, ['cat-file', '-e', `${ref}:${repoRelativePath}`]);
2007
+ } catch {
2008
+ existsAtRef = false;
2009
+ }
2010
+ if (existsAtRef) {
2011
+ await this.git(cwd, ['checkout', ref, '--', repoRelativePath]);
2012
+ } else {
2013
+ // First re-align the INDEX with HEAD for this path. A previous failed
2014
+ // attempt (the git-rm era of this method) can have left a staged
2015
+ // deletion — index entry gone — and `git add` can only stage a
2016
+ // deletion for a path the index still tracks. No-op on a clean tree.
2017
+ await this.git(cwd, ['reset', '-q', 'HEAD', '--', repoRelativePath]);
2018
+ // Then delete from the WORKING TREE only, never `git rm`: the
2019
+ // follow-up `commitFile` stages via `git add -- <path>`, and dropping
2020
+ // the index entry too made that add match nothing — the whole revert
2021
+ // failed with "pathspec did not match any files". A tracked file
2022
+ // missing from disk is exactly what `git add` records as deleted.
2023
+ await fs.rm(path.join(cwd, repoRelativePath), { force: true });
2024
+ }
1928
2025
  });
1929
2026
  }
1930
2027
 
@@ -183,10 +183,13 @@ export class PullRequestService implements IPullRequestService {
183
183
  return summaries;
184
184
  }
185
185
 
186
- async listPrsAuthoredBy(loginOrEmail: string): Promise<PullRequestSummary[]> {
186
+ async listPrsAuthoredBy(
187
+ loginOrEmail: string,
188
+ opts: { fresh?: boolean } = {},
189
+ ): Promise<PullRequestSummary[]> {
187
190
  const needle = loginOrEmail.trim().toLowerCase();
188
191
  if (!needle) return [];
189
- const prs = await this.listOpenPrs();
192
+ const prs = await this.listOpenPrs(opts);
190
193
  const needleIsEmail = needle.includes('@');
191
194
  const needleHash = needleIsEmail ? hashEmail(needle) : null;
192
195
  return prs.filter((p) => {
@@ -378,6 +381,11 @@ export class PullRequestService implements IPullRequestService {
378
381
  authorId: summary.authorId,
379
382
  viewerEmail: opts.viewerEmail,
380
383
  viewerCanBypassMerge,
384
+ // The reject route's third grant (see `rejectChangeRequest`): write on
385
+ // every changed file at origin/<base>. Derived from the per-file
386
+ // `viewerCanApprove` flags — the SAME `canWriteBatchAtRef` predicate the
387
+ // route enforces — so the hint cannot drift from the enforcement.
388
+ viewerWritesAllFiles: approvals.length > 0 && approvals.every((a) => a.viewerCanApprove),
381
389
  });
382
390
 
383
391
  const detail: PullRequestDetail = {
@@ -423,23 +431,26 @@ export class PullRequestService implements IPullRequestService {
423
431
  }
424
432
 
425
433
  /**
426
- * Pure predicate for the cancel-button's enabled state. The viewer can cancel
427
- * iff the PR is open AND they're either the author (hash-match against the
428
- * stored author) or an admin (`viewerCanBypassMerge` is the proxy — same
429
- * `canWriteAtRef('roles.yaml')` predicate). Fail-closed defaults: missing
430
- * email → false.
434
+ * Pure predicate for the `viewerCanCancel` hint. The viewer can cancel iff
435
+ * the PR is open AND they're the author (hash-match against the stored
436
+ * author), an admin (`viewerCanBypassMerge` is the proxy — same
437
+ * `canWriteAtRef('roles.yaml')` predicate), or they hold write on EVERY
438
+ * changed file (`viewerWritesAllFiles`) — the reject route's full
439
+ * authorization set, mirrored exactly so the hint never claims less (or
440
+ * more) than the server enforces. Fail-closed: no viewer email → false,
441
+ * whatever the grants say.
431
442
  */
432
443
  export function computeViewerCanCancel(input: {
433
444
  state: PullRequestState;
434
445
  authorId: string | undefined;
435
446
  viewerEmail: string | undefined;
436
447
  viewerCanBypassMerge: boolean;
448
+ viewerWritesAllFiles: boolean;
437
449
  }): boolean {
438
450
  if (input.state !== 'open') return false;
439
- const viewerIsAuthor = !!(
440
- input.authorId && input.viewerEmail && input.authorId === hashEmail(input.viewerEmail)
441
- );
442
- return viewerIsAuthor || input.viewerCanBypassMerge;
451
+ if (!input.viewerEmail) return false;
452
+ const viewerIsAuthor = !!(input.authorId && input.authorId === hashEmail(input.viewerEmail));
453
+ return viewerIsAuthor || input.viewerCanBypassMerge || input.viewerWritesAllFiles;
443
454
  }
444
455
 
445
456
  export const __testing = {
@@ -100,7 +100,10 @@ describe('ReviewWorkflowService.cancelPr', () => {
100
100
  ).rejects.toMatchObject({
101
101
  name: 'CancelAuthError',
102
102
  status: 403,
103
- message: expect.stringMatching(/only the author or an admin/i),
103
+ // Names the FULL authorization set — the facade's owner grant
104
+ // (write on every changed file) included, so the refusal never
105
+ // undersells who could actually do this.
106
+ message: expect.stringMatching(/author, an admin, or having edit access to every file/i),
104
107
  });
105
108
  expect(updateReturning).not.toHaveBeenCalled();
106
109
  });
@@ -106,7 +106,8 @@ class CancelStateError extends WorkflowDomainError {
106
106
  class CancelAuthError extends WorkflowDomainError {
107
107
  constructor() {
108
108
  super(
109
- "You can't cancel this change request — only the author or an admin can.",
109
+ "You can't cancel this change request — that takes being its author, an admin, " +
110
+ 'or having edit access to every file it changes.',
110
111
  403,
111
112
  );
112
113
  this.name = 'CancelAuthError';
@@ -500,7 +501,11 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
500
501
  })
501
502
  .onConflictDoNothing();
502
503
 
503
- return this.getApprovalStates(prNumber, files, headSha, baseBranch, prAuthorIdHash, workspaceId);
504
+ // WITH the caller as viewer: these approvals go straight back to the UI
505
+ // that just clicked, and omitting the viewer computed every
506
+ // `viewerCanApprove` as false — one approval made the apply button and
507
+ // every remaining approve control vanish until a fresh detail fetch.
508
+ return this.getApprovalStates(prNumber, files, headSha, baseBranch, prAuthorIdHash, workspaceId, user.email);
504
509
  }
505
510
 
506
511
  evaluateMergeGate(input: MergeGateInput): MergeGateResult {
@@ -801,6 +806,10 @@ export class ReviewWorkflowService implements IReviewWorkflowService {
801
806
  ),
802
807
  );
803
808
 
804
- return this.getApprovalStates(prNumber, files, headSha, baseBranch, prAuthorIdHash, workspaceId);
809
+ // WITH the caller as viewer: these approvals go straight back to the UI
810
+ // that just clicked, and omitting the viewer computed every
811
+ // `viewerCanApprove` as false — one approval made the apply button and
812
+ // every remaining approve control vanish until a fresh detail fetch.
813
+ return this.getApprovalStates(prNumber, files, headSha, baseBranch, prAuthorIdHash, workspaceId, user.email);
805
814
  }
806
815
  }
@@ -266,17 +266,6 @@ export function createWorkflowRoutes(
266
266
  }
267
267
  });
268
268
 
269
- router.post('/workspace/:id/workflow/changes/:sha/revert', async (req, res) => {
270
- const user = await requireUser(req, res);
271
- if (!user) return;
272
- try {
273
- res.json(await workflow.revertChange(req.params.id, user, req.params.sha));
274
- } catch (err) {
275
- const { status, body } = toHttpError(err);
276
- res.status(status).json(body);
277
- }
278
- });
279
-
280
269
  router.get('/workspace/:id/workflow/compare-file', async (req, res) => {
281
270
  if (!(await requireUser(req, res))) return;
282
271
  const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
@@ -312,6 +301,23 @@ export function createWorkflowRoutes(
312
301
  }
313
302
  });
314
303
 
304
+ router.get('/workspace/:id/workflow/file-at-change', async (req, res) => {
305
+ if (!(await requireUser(req, res))) return;
306
+ const pathParam = typeof req.query.path === 'string' ? req.query.path : '';
307
+ const sha = typeof req.query.sha === 'string' ? req.query.sha : '';
308
+ if (!pathParam || !sha) {
309
+ res.status(400).json({ error: 'path and sha are required' });
310
+ return;
311
+ }
312
+ try {
313
+ const { baseline, current } = await workflow.fileAtChange(req.params.id, pathParam, sha);
314
+ res.json({ baseline, current });
315
+ } catch (err) {
316
+ const { status, body } = toHttpError(err);
317
+ res.status(status).json(body);
318
+ }
319
+ });
320
+
315
321
  // ── File locks ────────────────────────────────────────────────────────────
316
322
 
317
323
  router.post('/workspace/:id/workflow/locks', async (req, res) => {
@@ -431,8 +437,12 @@ export function createWorkflowRoutes(
431
437
  router.get('/workflow/change-requests/mine', async (req, res) => {
432
438
  const user = await requireUser(req, res);
433
439
  if (!user) return;
440
+ // `?fresh=1` bypasses the 30s list cache — event-driven refreshes (a
441
+ // proposal or suggestion-routed upload just landed) ask because they KNOW
442
+ // the list changed, and a cached answer would hide their own change.
443
+ const fresh = isTruthyQuery(req.query.fresh);
434
444
  try {
435
- res.json(await workflow.listChangeRequestsAuthoredBy(user.email));
445
+ res.json(await workflow.listChangeRequestsAuthoredBy(user.email, { fresh }));
436
446
  } catch (err) {
437
447
  const { status, body } = toHttpError(err);
438
448
  res.status(status).json(body);
@@ -497,7 +507,7 @@ export function createWorkflowRoutes(
497
507
  // blip when the caller's workspace isn't yet provisioned.
498
508
  }
499
509
  }
500
- const detail = await workflow.getChangeRequestDetail(num, {
510
+ let detail = await workflow.getChangeRequestDetail(num, {
501
511
  fresh,
502
512
  workspaceId,
503
513
  viewerEmail,
@@ -506,6 +516,26 @@ export function createWorkflowRoutes(
506
516
  res.status(404).json({ error: 'change request not found' });
507
517
  return;
508
518
  }
519
+ // Lazy reconcile (the join-requests pattern): an OPEN request whose
520
+ // diff has emptied — everything it proposed has since landed, been
521
+ // reverted, or been undone on its branch — closes itself on
522
+ // observation and its branch retires. The close re-verifies the
523
+ // emptiness authoritatively and never fires on a failed diff.
524
+ if (detail.state === 'open' && detail.files.length === 0 && req.userId) {
525
+ try {
526
+ const user = await authService.getUserById(req.userId);
527
+ if (user && (await workflow.closeEmptyChangeRequest(num, user))) {
528
+ detail =
529
+ (await workflow.getChangeRequestDetail(num, {
530
+ fresh: true,
531
+ workspaceId,
532
+ viewerEmail,
533
+ })) ?? detail;
534
+ }
535
+ } catch (err) {
536
+ console.warn(`[cr] lazy empty-close of #${num} failed (non-fatal):`, err);
537
+ }
538
+ }
509
539
  res.json(detail);
510
540
  } catch (err) {
511
541
  const { status, body } = toHttpError(err);
@@ -513,6 +543,54 @@ export function createWorkflowRoutes(
513
543
  }
514
544
  });
515
545
 
546
+ /**
547
+ * Delete a change request outright — close it and retire its source
548
+ * branch. Admin-only; the moderation verb for a shared deployment.
549
+ */
550
+ router.delete('/workflow/change-requests/:number', async (req, res) => {
551
+ const num = parsePrNumber(req.params.number);
552
+ if (num === null) {
553
+ res.status(400).json({ error: 'invalid change request number' });
554
+ return;
555
+ }
556
+ const user = await requireUser(req, res);
557
+ if (!user) return;
558
+ try {
559
+ await workflow.deleteChangeRequest(num, user);
560
+ res.json({ deleted: true });
561
+ } catch (err) {
562
+ const { status, body } = toHttpError(err);
563
+ res.status(status).json(body);
564
+ }
565
+ });
566
+
567
+ /**
568
+ * Decline ONE file of a change request: restore its merge-base version on
569
+ * the source branch, dropping it from the request's diff. Takes the same
570
+ * permission as approving the file. When the last file is declined the
571
+ * request closes itself and its branch retires (`closed: true`).
572
+ */
573
+ router.post('/workflow/change-requests/:number/files/revert', async (req, res) => {
574
+ const num = parsePrNumber(req.params.number);
575
+ if (num === null) {
576
+ res.status(400).json({ error: 'invalid change request number' });
577
+ return;
578
+ }
579
+ const user = await requireUser(req, res);
580
+ if (!user) return;
581
+ const body = (req.body ?? {}) as { path?: string };
582
+ if (typeof body.path !== 'string' || !body.path) {
583
+ res.status(400).json({ error: 'path is required' });
584
+ return;
585
+ }
586
+ try {
587
+ res.json(await workflow.revertChangeRequestFile(num, user, body.path));
588
+ } catch (err) {
589
+ const { status, body: errBody } = toHttpError(err);
590
+ res.status(status).json(errBody);
591
+ }
592
+ });
593
+
516
594
  router.post('/workflow/change-requests', async (req, res) => {
517
595
  const user = await requireUser(req, res);
518
596
  if (!user) return;