limn-review 0.1.123

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 (474) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +133 -0
  3. package/dist/ai/runtime.js +535 -0
  4. package/dist/api/plugins/auth.js +169 -0
  5. package/dist/api/plugins/error-handler.js +114 -0
  6. package/dist/api/plugins/rate-limit.js +855 -0
  7. package/dist/api/plugins/security.js +321 -0
  8. package/dist/api/routes/activity.js +103 -0
  9. package/dist/api/routes/ai-runtime.js +67 -0
  10. package/dist/api/routes/auth.js +194 -0
  11. package/dist/api/routes/billing.js +209 -0
  12. package/dist/api/routes/bot-triage.js +1289 -0
  13. package/dist/api/routes/conflicts.js +730 -0
  14. package/dist/api/routes/contact.js +318 -0
  15. package/dist/api/routes/daily-brief.js +58 -0
  16. package/dist/api/routes/flow.js +71 -0
  17. package/dist/api/routes/health.js +7 -0
  18. package/dist/api/routes/insights.js +200 -0
  19. package/dist/api/routes/me.js +458 -0
  20. package/dist/api/routes/mentions.js +22 -0
  21. package/dist/api/routes/mergers.js +8 -0
  22. package/dist/api/routes/ml-labels.js +109 -0
  23. package/dist/api/routes/open-prs.js +35 -0
  24. package/dist/api/routes/prs.js +1633 -0
  25. package/dist/api/routes/reactions.js +217 -0
  26. package/dist/api/routes/repos.js +563 -0
  27. package/dist/api/routes/search.js +41 -0
  28. package/dist/api/routes/threads.js +118 -0
  29. package/dist/api/routes/timeline.js +150 -0
  30. package/dist/api/routes/users.js +67 -0
  31. package/dist/api/routes/webhooks.js +181 -0
  32. package/dist/api/routes/workspaces.js +322 -0
  33. package/dist/app.js +274 -0
  34. package/dist/ascii.js +19 -0
  35. package/dist/auth/account.js +482 -0
  36. package/dist/auth/crypto.js +46 -0
  37. package/dist/bot-triage/resolve.js +59 -0
  38. package/dist/cli.js +388 -0
  39. package/dist/coding/activity.js +80 -0
  40. package/dist/coding/agent.js +288 -0
  41. package/dist/coding/ai-fix/auto-fix.js +212 -0
  42. package/dist/coding/ai-fix/manager.js +370 -0
  43. package/dist/coding/ai-fix/persist.js +213 -0
  44. package/dist/coding/ai-fix/pr-context.js +58 -0
  45. package/dist/coding/ai-fix/prompts.js +138 -0
  46. package/dist/coding/ai-fix/review-seed.js +357 -0
  47. package/dist/coding/ai-fix/routes.js +425 -0
  48. package/dist/coding/git-ops.js +321 -0
  49. package/dist/coding/git.js +184 -0
  50. package/dist/coding/merge.js +262 -0
  51. package/dist/coding/safe-path.js +75 -0
  52. package/dist/coding/schema.js +43 -0
  53. package/dist/config.js +648 -0
  54. package/dist/conflict/diff.js +304 -0
  55. package/dist/conflict/git.js +118 -0
  56. package/dist/conflict/hash.js +73 -0
  57. package/dist/conflict/janitor.js +218 -0
  58. package/dist/conflict/land.js +675 -0
  59. package/dist/conflict/model-types.js +2 -0
  60. package/dist/conflict/model.js +1081 -0
  61. package/dist/conflict/parse.js +142 -0
  62. package/dist/conflict/seam.js +136 -0
  63. package/dist/conflict/session.js +532 -0
  64. package/dist/conflict/suggestion.js +398 -0
  65. package/dist/conflict/tokens.js +72 -0
  66. package/dist/data-dir.js +112 -0
  67. package/dist/db/actor-lanes.js +213 -0
  68. package/dist/db/automation-ids.js +97 -0
  69. package/dist/db/automation-output.js +349 -0
  70. package/dist/db/blast-radius-query.js +36 -0
  71. package/dist/db/blast-radius.js +226 -0
  72. package/dist/db/bot-dormancy.js +115 -0
  73. package/dist/db/bot-overlap.js +579 -0
  74. package/dist/db/bot-volume.js +722 -0
  75. package/dist/db/bot-window.js +35 -0
  76. package/dist/db/branch-queries.js +310 -0
  77. package/dist/db/change-shape.js +243 -0
  78. package/dist/db/changepoint.js +75 -0
  79. package/dist/db/cleanup.js +36 -0
  80. package/dist/db/client.js +102 -0
  81. package/dist/db/code-loc.js +388 -0
  82. package/dist/db/credits.js +79 -0
  83. package/dist/db/daily-brief.js +406 -0
  84. package/dist/db/dependency-cards.js +145 -0
  85. package/dist/db/erase-account.js +255 -0
  86. package/dist/db/export-account.js +189 -0
  87. package/dist/db/failing-checks.js +124 -0
  88. package/dist/db/file-coupling.js +197 -0
  89. package/dist/db/flow-detail.js +661 -0
  90. package/dist/db/flow-pointers.js +197 -0
  91. package/dist/db/flow-settings.js +67 -0
  92. package/dist/db/forecast.js +114 -0
  93. package/dist/db/line-overlap.js +81 -0
  94. package/dist/db/merge-queue.js +189 -0
  95. package/dist/db/migrate.js +17 -0
  96. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  97. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  98. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  99. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  100. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  101. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  102. package/dist/db/migrations/0006_cute_violations.sql +45 -0
  103. package/dist/db/migrations/0007_dark_anthem.sql +2 -0
  104. package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
  105. package/dist/db/migrations/0009_lean_bodies.sql +13 -0
  106. package/dist/db/migrations/0010_lean_bodies_nullable.sql +56 -0
  107. package/dist/db/migrations/0011_pr_diff_size.sql +10 -0
  108. package/dist/db/migrations/0012_findings_included_default.sql +7 -0
  109. package/dist/db/migrations/0013_review_routing.sql +9 -0
  110. package/dist/db/migrations/0014_account_last_active.sql +6 -0
  111. package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
  112. package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
  113. package/dist/db/migrations/0017_account_display_name.sql +7 -0
  114. package/dist/db/migrations/0018_finding_comment_kind.sql +7 -0
  115. package/dist/db/migrations/0019_finding_file_in_diff.sql +7 -0
  116. package/dist/db/migrations/0020_claude_review_cost_telemetry.sql +11 -0
  117. package/dist/db/migrations/0021_account_feed_last_seen.sql +7 -0
  118. package/dist/db/migrations/0022_ci_status_events.sql +21 -0
  119. package/dist/db/migrations/0023_ai_usage.sql +21 -0
  120. package/dist/db/migrations/0024_pr_head_ref_name.sql +6 -0
  121. package/dist/db/migrations/0025_account_billing.sql +7 -0
  122. package/dist/db/migrations/0026_ai_credit_allowance.sql +5 -0
  123. package/dist/db/migrations/0027_user_github_type.sql +6 -0
  124. package/dist/db/migrations/0028_bot_review_classification.sql +21 -0
  125. package/dist/db/migrations/0029_bot_mute_rules.sql +19 -0
  126. package/dist/db/migrations/0030_teams.sql +32 -0
  127. package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
  128. package/dist/db/migrations/0032_benchmark_contributions.sql +29 -0
  129. package/dist/db/migrations/0033_thread_addressed_confidence.sql +9 -0
  130. package/dist/db/migrations/0034_thread_resolved_at.sql +6 -0
  131. package/dist/db/migrations/0035_pr_first_review_requested_at.sql +5 -0
  132. package/dist/db/migrations/0036_search_index.sql +25 -0
  133. package/dist/db/migrations/0037_author_indexes.sql +15 -0
  134. package/dist/db/migrations/0038_auto_merge_requests.sql +37 -0
  135. package/dist/db/migrations/0039_branch_status.sql +44 -0
  136. package/dist/db/migrations/0040_pr_review_decision.sql +10 -0
  137. package/dist/db/migrations/0041_branch_commit_detail.sql +38 -0
  138. package/dist/db/migrations/0042_repo_reviewers.sql +176 -0
  139. package/dist/db/migrations/0043_account_reviewers.sql +131 -0
  140. package/dist/db/migrations/0044_workspaces.sql +138 -0
  141. package/dist/db/migrations/0045_workspace_reviewers.sql +180 -0
  142. package/dist/db/migrations/0046_drop_inbox_watch.sql +25 -0
  143. package/dist/db/migrations/0047_ml_comment_labels.sql +40 -0
  144. package/dist/db/migrations/0048_ml_vendor_severity.sql +31 -0
  145. package/dist/db/migrations/0049_seat_pricing_repo_description.sql +21 -0
  146. package/dist/db/migrations/0050_users_app_slug.sql +12 -0
  147. package/dist/db/migrations/0051_auto_merge_queue.sql +17 -0
  148. package/dist/db/migrations/0052_trunk_ci_status_events.sql +43 -0
  149. package/dist/db/migrations/0053_automation_roles.sql +79 -0
  150. package/dist/db/migrations/0054_automation_vendor_kinds.sql +514 -0
  151. package/dist/db/migrations/0055_auto_merge_phase.sql +12 -0
  152. package/dist/db/migrations/0056_pr_mentions.sql +22 -0
  153. package/dist/db/migrations/0057_large_pr_threshold.sql +14 -0
  154. package/dist/db/migrations/0058_pending_mute.sql +53 -0
  155. package/dist/db/migrations/0059_merge_queue_state.sql +33 -0
  156. package/dist/db/migrations/0060_drop_my_turn_dismissals.sql +33 -0
  157. package/dist/db/migrations/0061_auto_merge_durable_anchors.sql +27 -0
  158. package/dist/db/migrations/0062_blast_radius_config.sql +21 -0
  159. package/dist/db/migrations/0063_repo_file_coupling.sql +33 -0
  160. package/dist/db/migrations/0064_pr_content_kind.sql +21 -0
  161. package/dist/db/migrations/0065_workspace_flow_settings.sql +13 -0
  162. package/dist/db/migrations/0066_review_request_events.sql +31 -0
  163. package/dist/db/migrations/0067_pr_security_signals.sql +86 -0
  164. package/dist/db/migrations/0068_my_turn_settings.sql +18 -0
  165. package/dist/db/migrations/0069_my_turn_dismissals.sql +41 -0
  166. package/dist/db/migrations/0070_claude_review_follow_up_ticket.sql +22 -0
  167. package/dist/db/migrations/0071_claude_review_trigger.sql +11 -0
  168. package/dist/db/migrations/0072_commit_message_headline.sql +13 -0
  169. package/dist/db/migrations/0073_claude_review_chat.sql +31 -0
  170. package/dist/db/migrations/0074_adopt_agentic_tables.sql +98 -0
  171. package/dist/db/migrations/0075_review_lens_drop_learnings.sql +15 -0
  172. package/dist/db/migrations/0076_claude_review_threads.sql +17 -0
  173. package/dist/db/migrations/0077_claude_review_ci_failures.sql +10 -0
  174. package/dist/db/migrations/0078_ai_fix_change_report.sql +17 -0
  175. package/dist/db/migrations/0079_claude_finding_story.sql +16 -0
  176. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  177. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  178. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  179. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  180. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  181. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  182. package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
  183. package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
  184. package/dist/db/migrations/meta/_journal.json +566 -0
  185. package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
  186. package/dist/db/migrations-pg/0001_dizzy_mockingbird.sql +3 -0
  187. package/dist/db/migrations-pg/0002_glorious_ikaris.sql +4 -0
  188. package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
  189. package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
  190. package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
  191. package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
  192. package/dist/db/migrations-pg/0007_same_human_torch.sql +2 -0
  193. package/dist/db/migrations-pg/0008_slimy_toxin.sql +1 -0
  194. package/dist/db/migrations-pg/0009_fuzzy_jubilee.sql +4 -0
  195. package/dist/db/migrations-pg/0010_late_hammerhead.sql +1 -0
  196. package/dist/db/migrations-pg/0011_happy_leech.sql +16 -0
  197. package/dist/db/migrations-pg/0012_woozy_captain_midlands.sql +16 -0
  198. package/dist/db/migrations-pg/0013_yielding_nicolaos.sql +1 -0
  199. package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
  200. package/dist/db/migrations-pg/0015_aspiring_black_tom.sql +1 -0
  201. package/dist/db/migrations-pg/0016_cute_ghost_rider.sql +30 -0
  202. package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
  203. package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
  204. package/dist/db/migrations-pg/0019_benchmark_contributions.sql +22 -0
  205. package/dist/db/migrations-pg/0020_majestic_edwin_jarvis.sql +3 -0
  206. package/dist/db/migrations-pg/0021_green_salo.sql +1 -0
  207. package/dist/db/migrations-pg/0022_wise_baron_strucker.sql +1 -0
  208. package/dist/db/migrations-pg/0023_search_index.sql +19 -0
  209. package/dist/db/migrations-pg/0024_author_indexes.sql +9 -0
  210. package/dist/db/migrations-pg/0025_auto_merge_requests.sql +26 -0
  211. package/dist/db/migrations-pg/0026_branch_status.sql +32 -0
  212. package/dist/db/migrations-pg/0027_pr_review_decision.sql +5 -0
  213. package/dist/db/migrations-pg/0028_branch_commit_detail.sql +9 -0
  214. package/dist/db/migrations-pg/0029_repo_reviewers.sql +127 -0
  215. package/dist/db/migrations-pg/0030_account_reviewers.sql +77 -0
  216. package/dist/db/migrations-pg/0031_workspaces.sql +131 -0
  217. package/dist/db/migrations-pg/0032_workspace_reviewers.sql +165 -0
  218. package/dist/db/migrations-pg/0033_drop_inbox_watch.sql +14 -0
  219. package/dist/db/migrations-pg/0034_ml_comment_labels.sql +33 -0
  220. package/dist/db/migrations-pg/0035_ml_vendor_severity.sql +16 -0
  221. package/dist/db/migrations-pg/0036_seat_pricing_repo_description.sql +12 -0
  222. package/dist/db/migrations-pg/0037_users_app_slug.sql +9 -0
  223. package/dist/db/migrations-pg/0038_auto_merge_queue.sql +10 -0
  224. package/dist/db/migrations-pg/0039_trunk_ci_status_events.sql +27 -0
  225. package/dist/db/migrations-pg/0040_automation_roles.sql +63 -0
  226. package/dist/db/migrations-pg/0041_automation_vendor_kinds.sql +488 -0
  227. package/dist/db/migrations-pg/0042_auto_merge_phase.sql +8 -0
  228. package/dist/db/migrations-pg/0043_pr_mentions.sql +19 -0
  229. package/dist/db/migrations-pg/0044_large_pr_threshold.sql +6 -0
  230. package/dist/db/migrations-pg/0045_pending_mute.sql +26 -0
  231. package/dist/db/migrations-pg/0046_merge_queue_state.sql +18 -0
  232. package/dist/db/migrations-pg/0047_drop_my_turn_dismissals.sql +21 -0
  233. package/dist/db/migrations-pg/0048_auto_merge_durable_anchors.sql +27 -0
  234. package/dist/db/migrations-pg/0049_blast_radius_config.sql +8 -0
  235. package/dist/db/migrations-pg/0050_repo_file_coupling.sql +13 -0
  236. package/dist/db/migrations-pg/0051_pr_content_kind.sql +6 -0
  237. package/dist/db/migrations-pg/0052_workspace_flow_settings.sql +4 -0
  238. package/dist/db/migrations-pg/0053_review_request_events.sql +18 -0
  239. package/dist/db/migrations-pg/0054_pr_security_signals.sql +59 -0
  240. package/dist/db/migrations-pg/0055_my_turn_settings.sql +6 -0
  241. package/dist/db/migrations-pg/0056_my_turn_dismissals.sql +21 -0
  242. package/dist/db/migrations-pg/0057_claude_review_follow_up_ticket.sql +8 -0
  243. package/dist/db/migrations-pg/0058_claude_review_trigger.sql +4 -0
  244. package/dist/db/migrations-pg/0059_commit_message_headline.sql +3 -0
  245. package/dist/db/migrations-pg/0060_claude_review_chat.sql +24 -0
  246. package/dist/db/migrations-pg/0061_adopt_agentic_tables.sql +79 -0
  247. package/dist/db/migrations-pg/0062_review_lens_drop_learnings.sql +5 -0
  248. package/dist/db/migrations-pg/0063_claude_review_threads.sql +7 -0
  249. package/dist/db/migrations-pg/0064_claude_review_ci_failures.sql +4 -0
  250. package/dist/db/migrations-pg/0065_ai_fix_change_report.sql +9 -0
  251. package/dist/db/migrations-pg/0066_claude_finding_story.sql +7 -0
  252. package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
  253. package/dist/db/migrations-pg/meta/0001_snapshot.json +2143 -0
  254. package/dist/db/migrations-pg/meta/0002_snapshot.json +2170 -0
  255. package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
  256. package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
  257. package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
  258. package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
  259. package/dist/db/migrations-pg/meta/0007_snapshot.json +2219 -0
  260. package/dist/db/migrations-pg/meta/0008_snapshot.json +2226 -0
  261. package/dist/db/migrations-pg/meta/0009_snapshot.json +2250 -0
  262. package/dist/db/migrations-pg/meta/0010_snapshot.json +2256 -0
  263. package/dist/db/migrations-pg/meta/0011_snapshot.json +2412 -0
  264. package/dist/db/migrations-pg/meta/0012_snapshot.json +2528 -0
  265. package/dist/db/migrations-pg/meta/0013_snapshot.json +2534 -0
  266. package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
  267. package/dist/db/migrations-pg/meta/0015_snapshot.json +2553 -0
  268. package/dist/db/migrations-pg/meta/0016_snapshot.json +2774 -0
  269. package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
  270. package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
  271. package/dist/db/migrations-pg/meta/0020_snapshot.json +3228 -0
  272. package/dist/db/migrations-pg/meta/0021_snapshot.json +3234 -0
  273. package/dist/db/migrations-pg/meta/0022_snapshot.json +3240 -0
  274. package/dist/db/migrations-pg/meta/_journal.json +475 -0
  275. package/dist/db/ml-labels.js +1666 -0
  276. package/dist/db/my-turn-dismissals.js +343 -0
  277. package/dist/db/my-turn-settings.js +34 -0
  278. package/dist/db/pending-classify.js +46 -0
  279. package/dist/db/pending-mute.js +171 -0
  280. package/dist/db/pending-tabs.js +238 -0
  281. package/dist/db/period-metrics.js +820 -0
  282. package/dist/db/person-period.js +763 -0
  283. package/dist/db/pr-intervals.js +930 -0
  284. package/dist/db/pr-liveness.js +240 -0
  285. package/dist/db/pr-mentions.js +279 -0
  286. package/dist/db/pr-merge-queue-stamp.js +91 -0
  287. package/dist/db/queries.js +14631 -0
  288. package/dist/db/reaction-targets.js +94 -0
  289. package/dist/db/repo-activity.js +171 -0
  290. package/dist/db/retention.js +138 -0
  291. package/dist/db/review-threads-for-review.js +240 -0
  292. package/dist/db/run-migrations.js +24 -0
  293. package/dist/db/schema.pg.js +1220 -0
  294. package/dist/db/schema.sqlite.js +1801 -0
  295. package/dist/db/search.js +317 -0
  296. package/dist/db/security-alerts.js +218 -0
  297. package/dist/db/synthesis-input.js +565 -0
  298. package/dist/db/triage.js +320 -0
  299. package/dist/db/usage.js +63 -0
  300. package/dist/db/work-plan.js +1086 -0
  301. package/dist/db/working-hours.js +161 -0
  302. package/dist/feed/my-turn.js +122 -0
  303. package/dist/github/actions-logs.js +397 -0
  304. package/dist/github/auth.js +86 -0
  305. package/dist/github/branch-queries.js +229 -0
  306. package/dist/github/client.js +454 -0
  307. package/dist/github/codeowners.js +221 -0
  308. package/dist/github/commit-checks.js +105 -0
  309. package/dist/github/compare.js +110 -0
  310. package/dist/github/diff-anchor.js +227 -0
  311. package/dist/github/mutations.js +706 -0
  312. package/dist/github/pr-file-content.js +254 -0
  313. package/dist/github/pr-liveness.js +161 -0
  314. package/dist/github/queries.js +875 -0
  315. package/dist/github/rate-budget.js +129 -0
  316. package/dist/github/reactions.js +161 -0
  317. package/dist/github/reviewer-suggest.js +104 -0
  318. package/dist/github/team-reviewers.js +96 -0
  319. package/dist/index.js +184 -0
  320. package/dist/merge/auto-merge-runner.js +960 -0
  321. package/dist/ml/severity-client.js +218 -0
  322. package/dist/pr/detail-enricher.js +26 -0
  323. package/dist/pro/bind.js +302 -0
  324. package/dist/pro/contract.js +37 -0
  325. package/dist/pro/migrate.js +64 -0
  326. package/dist/review/agent-context.js +127 -0
  327. package/dist/review/agent.js +385 -0
  328. package/dist/review/agentic.js +59 -0
  329. package/dist/review/auth.js +94 -0
  330. package/dist/review/chat-agent.js +179 -0
  331. package/dist/review/claude-review/ai-ready.js +16 -0
  332. package/dist/review/claude-review/auto-settings.js +139 -0
  333. package/dist/review/claude-review/auto.js +244 -0
  334. package/dist/review/claude-review/chat.js +489 -0
  335. package/dist/review/claude-review/ci-failures.js +350 -0
  336. package/dist/review/claude-review/follow-up.js +310 -0
  337. package/dist/review/claude-review/manager.js +686 -0
  338. package/dist/review/claude-review/persist.js +996 -0
  339. package/dist/review/claude-review/prompts.js +357 -0
  340. package/dist/review/claude-review/routes.js +518 -0
  341. package/dist/review/claude-review/routing.js +61 -0
  342. package/dist/review/claude-review/specialists.js +190 -0
  343. package/dist/review/claude-review/threads.js +265 -0
  344. package/dist/review/claude-review/ticket.js +282 -0
  345. package/dist/review/clone-hygiene.js +380 -0
  346. package/dist/review/clone-manager.js +367 -0
  347. package/dist/review/llm.js +113 -0
  348. package/dist/review/model-options.js +48 -0
  349. package/dist/review/plugin-providers.js +12 -0
  350. package/dist/review/post-review.js +216 -0
  351. package/dist/review/post-seam.js +161 -0
  352. package/dist/review/prepare.js +168 -0
  353. package/dist/review/pricing.js +17 -0
  354. package/dist/review/schema.js +156 -0
  355. package/dist/review/submit-map.js +41 -0
  356. package/dist/review/usage.js +90 -0
  357. package/dist/shared/claude-review.js +335 -0
  358. package/dist/shared/conflict-fold.js +151 -0
  359. package/dist/shared/conflicts.js +30 -0
  360. package/dist/shared/flow-settings.js +191 -0
  361. package/dist/shared/index.js +8 -0
  362. package/dist/shared/my-turn-settings.js +238 -0
  363. package/dist/shared/pending-rules.js +281 -0
  364. package/dist/shared/product.js +54 -0
  365. package/dist/shared/types.js +896 -0
  366. package/dist/status.js +386 -0
  367. package/dist/sync/adaptive.js +185 -0
  368. package/dist/sync/app-attribution.js +66 -0
  369. package/dist/sync/auth-notices.js +36 -0
  370. package/dist/sync/backfill-ci-history.js +315 -0
  371. package/dist/sync/backfill-commit-headlines.js +136 -0
  372. package/dist/sync/backfill-pr-security.js +175 -0
  373. package/dist/sync/backfill-review-requests.js +97 -0
  374. package/dist/sync/benchmark-rollup.js +50 -0
  375. package/dist/sync/bot-detection.js +309 -0
  376. package/dist/sync/bot-resolution-markers.js +30 -0
  377. package/dist/sync/branch-status.js +634 -0
  378. package/dist/sync/classify-change-shape.js +150 -0
  379. package/dist/sync/commit-files.js +74 -0
  380. package/dist/sync/derive-thread-state.js +99 -0
  381. package/dist/sync/drain-review-threads.js +212 -0
  382. package/dist/sync/hydrate-detail.js +482 -0
  383. package/dist/sync/mention-scan.js +89 -0
  384. package/dist/sync/ml-enrichment.js +478 -0
  385. package/dist/sync/pr-change-signal.js +96 -0
  386. package/dist/sync/pr-liveness-sweep.js +139 -0
  387. package/dist/sync/pr-settle.js +381 -0
  388. package/dist/sync/refresh-pr.js +163 -0
  389. package/dist/sync/reparse-vendor-badges.js +495 -0
  390. package/dist/sync/resync-after-write.js +280 -0
  391. package/dist/sync/review-fingerprint.js +117 -0
  392. package/dist/sync/reviewer-behavior.js +103 -0
  393. package/dist/sync/reviewer-classify.js +503 -0
  394. package/dist/sync/routing-files.js +97 -0
  395. package/dist/sync/scheduled-jobs.js +12 -0
  396. package/dist/sync/scheduler.js +159 -0
  397. package/dist/sync/security-detect.js +521 -0
  398. package/dist/sync/sync-manager.js +750 -0
  399. package/dist/sync/sync-one-pr.js +205 -0
  400. package/dist/sync/sync-repo.js +400 -0
  401. package/dist/sync/unsettled-prs.js +506 -0
  402. package/dist/sync/upsert.js +1190 -0
  403. package/package.json +56 -0
  404. package/public/apple-touch-icon.png +0 -0
  405. package/public/assets/AiFixTab-CQP6PT5u.js +2 -0
  406. package/public/assets/ClaudeReviewTab-DQJ-58ZA.js +8 -0
  407. package/public/assets/PrBotBehaviourTab-CCnFrWbC.js +1 -0
  408. package/public/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  409. package/public/assets/highlight-BEHUn5zE.css +10 -0
  410. package/public/assets/highlight-CIIZLa6Z.js +12 -0
  411. package/public/assets/index-BDnYs6zg.js +73 -0
  412. package/public/assets/index-mCuI--RC.css +1 -0
  413. package/public/assets/markdown-I_JLLVAZ.js +32 -0
  414. package/public/assets/perfProbe-Da13EhTE.js +1 -0
  415. package/public/assets/tanstack-5qsxxaLe.js +17 -0
  416. package/public/assets/vis-C3BCkP5U.js +1280 -0
  417. package/public/assets/vis-ziCVehMy.css +1 -0
  418. package/public/favicon-16.png +0 -0
  419. package/public/favicon-32.png +0 -0
  420. package/public/favicon-48.png +0 -0
  421. package/public/favicon.svg +5 -0
  422. package/public/index.html +41 -0
  423. package/public-landing/apple-touch-icon.png +0 -0
  424. package/public-landing/assets/archivo-latin-500-BWmvx2Mg.woff2 +0 -0
  425. package/public-landing/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  426. package/public-landing/assets/index-CA3eEGXO.js +40 -0
  427. package/public-landing/assets/index-naQrQ6Fe.css +1 -0
  428. package/public-landing/assets/jetbrains-mono-latin-400-RruCX7RW.woff2 +0 -0
  429. package/public-landing/assets/newsreader-latin-400-DExrRRrN.woff2 +0 -0
  430. package/public-landing/assets/newsreader-latin-400-italic-b0I-AwbV.woff2 +0 -0
  431. package/public-landing/bots/index.html +156 -0
  432. package/public-landing/contact/index.html +156 -0
  433. package/public-landing/cookies/index.html +156 -0
  434. package/public-landing/demo/limn-walkthrough-poster.jpg +0 -0
  435. package/public-landing/demo/limn-walkthrough.cues.json +44 -0
  436. package/public-landing/demo/limn-walkthrough.mp4 +0 -0
  437. package/public-landing/favicon-16.png +0 -0
  438. package/public-landing/favicon-32.png +0 -0
  439. package/public-landing/favicon-48.png +0 -0
  440. package/public-landing/favicon.svg +5 -0
  441. package/public-landing/features/index.html +156 -0
  442. package/public-landing/for-developers/index.html +156 -0
  443. package/public-landing/for-managers/index.html +156 -0
  444. package/public-landing/how-it-works/index.html +156 -0
  445. package/public-landing/how-we-measure/index.html +156 -0
  446. package/public-landing/icon-192.png +0 -0
  447. package/public-landing/icon-512.png +0 -0
  448. package/public-landing/index.html +156 -0
  449. package/public-landing/insights/index.html +156 -0
  450. package/public-landing/og-image.png +0 -0
  451. package/public-landing/pricing/index.html +156 -0
  452. package/public-landing/privacy/index.html +156 -0
  453. package/public-landing/pro/index.html +156 -0
  454. package/public-landing/reviews/index.html +156 -0
  455. package/public-landing/robots.txt +8 -0
  456. package/public-landing/shots/benchmark.png +0 -0
  457. package/public-landing/shots/bot-roi.png +0 -0
  458. package/public-landing/shots/bot-settings.png +0 -0
  459. package/public-landing/shots/chronology.png +0 -0
  460. package/public-landing/shots/feed.png +0 -0
  461. package/public-landing/shots/flow-metrics.png +0 -0
  462. package/public-landing/shots/free-reports.png +0 -0
  463. package/public-landing/shots/pending-board.png +0 -0
  464. package/public-landing/shots/pending-card.png +0 -0
  465. package/public-landing/shots/period-report.png +0 -0
  466. package/public-landing/shots/pr-detail.png +0 -0
  467. package/public-landing/shots/pr-threads.png +0 -0
  468. package/public-landing/shots/reach.png +0 -0
  469. package/public-landing/shots/repo-rows.png +0 -0
  470. package/public-landing/shots/severity-findings.png +0 -0
  471. package/public-landing/shots/severity-strip.png +0 -0
  472. package/public-landing/site.webmanifest +14 -0
  473. package/public-landing/sitemap.xml +51 -0
  474. package/public-landing/terms/index.html +156 -0
@@ -0,0 +1,855 @@
1
+ import { config } from '../../config.js';
2
+ // A fixed window (not a sliding one) is deliberate: it is O(1) in memory per key,
3
+ // and the worst case — 2× the limit across a window boundary — is irrelevant when
4
+ // the limits are this far above normal human use.
5
+ const MINUTE = 60_000;
6
+ const HOUR = 60 * MINUTE;
7
+ // ---- The tiers ----
8
+ //
9
+ // Sized so a person driving the UI hard never sees a 429, while a script in a loop
10
+ // hits one within seconds. Every number is overridable via env for a deployment that
11
+ // needs to tune (RATE_LIMIT_<TIER>_PER_MIN).
12
+ function tier(name, perWindow, windowMs) {
13
+ const raw = process.env[`RATE_LIMIT_${name.toUpperCase().replace(/-/g, '_')}`];
14
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
15
+ return {
16
+ name,
17
+ limit: Number.isFinite(n) && n > 0 ? n : perWindow,
18
+ windowMs,
19
+ };
20
+ }
21
+ const TIERS = {
22
+ // Anthropic-spending generation. A digest refresh, a sprint report, an Insights
23
+ // chat turn, a themes rebuild, a PR summary, an agentic review or fix. The most
24
+ // expensive thing in the app and the tightest bucket.
25
+ ai: tier('ai', 20, MINUTE),
26
+ // A second, longer window on the same routes: 20/min would still be 1200/hour.
27
+ aiHourly: tier('ai_hourly', 120, HOUR),
28
+ // Writes that hit the GitHub API — posting a review, resolving threads, approving,
29
+ // merging, commenting. Bounded by the tenant's GitHub quota, which is shared and
30
+ // exhaustible, so worth a bucket of its own.
31
+ githubWrite: tier('github_write', 60, MINUTE),
32
+ // Sync triggers. A full backfill is minutes of GraphQL; adding a repo starts one.
33
+ sync: tier('sync', 20, MINUTE),
34
+ // Live GitHub repo search — one upstream call per keystroke burst.
35
+ search: tier('search', 60, MINUTE),
36
+ // GET /api/prs/:id. Looks like a plain read, but under lean storage (the DEFAULT in both
37
+ // modes) each call runs PR_DETAIL_QUERY against GitHub to hydrate the bodies — so a loop
38
+ // over PR ids converts HTTP requests 1:1 into GraphQL requests and drains the tenant's
39
+ // 5,000 points/hour, breaking their sync and write actions for the rest of the hour. A
40
+ // person opens a handful of PRs a minute; 60 leaves enormous headroom while keeping the
41
+ // hourly worst case inside the GitHub budget. Paired with the 60s hydration cache +
42
+ // in-flight coalescing in sync/hydrate-detail.ts, which make repeats free.
43
+ prDetail: tier('pr_detail', 60, MINUTE),
44
+ // Everything else under /api. A generous blanket backstop that also catches a
45
+ // runaway polling loop in the frontend before it becomes a support ticket.
46
+ read: tier('read', 600, MINUTE),
47
+ // Sign-in. Unauthenticated, so keyed by IP; the callback makes two GitHub calls.
48
+ auth: tier('auth', 30, MINUTE),
49
+ // The public contact form. Unauthenticated, so keyed by IP — and the tightest bucket
50
+ // in the file, because this is the ONE route whose cost is a HUMAN's attention: every
51
+ // accepted request puts a message in front of somebody. Five an hour is more messages
52
+ // than any real visitor sends and few enough that a bot which gets past the honeypot
53
+ // and the signed fill-time ticket still cannot fill a channel. The ticket mint shares
54
+ // it: minting is what a bot has to do first, so limiting it limits the attempts.
55
+ contact: tier('contact', 5, HOUR),
56
+ // Signed webhooks. Legitimately bursty (a busy org pushing), so high — but not
57
+ // unbounded, since each delivery can enqueue a sync.
58
+ webhook: tier('webhook', 600, MINUTE),
59
+ };
60
+ // ---- Route → tier classification ----
61
+ //
62
+ // Matched on the raw path, cheapest checks first. Kept as explicit predicates rather
63
+ // than a regex table so each decision is readable and greppable from the route file
64
+ // it refers to.
65
+ function tierFor(method, path) {
66
+ const mutating = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
67
+ // Signed server-to-server posts.
68
+ if (path === '/api/webhooks/github' || path === '/api/billing/webhook') {
69
+ return [TIERS.webhook];
70
+ }
71
+ if (path.startsWith('/api/auth/'))
72
+ return [TIERS.auth];
73
+ // ---- The public contact form ----
74
+ // ANONYMOUS in the only mode it runs in, so `keyFor` falls through to the IP — which is
75
+ // the whole reason this tier exists as its own bucket rather than inheriting anything: every other unauthenticated
76
+ // surface here is either signature-verified (the two webhooks) or bounded by GitHub's
77
+ // own sign-in flow. FOLLOWING THE TOKEN gives an unusual answer: the route spends no
78
+ // GitHub quota, no model dollars and barely any database — one outbound webhook POST —
79
+ // and yet it belongs in the strictest bucket in the file, because what it actually
80
+ // spends is a PERSON READING A MESSAGE. The blanket 600/min `read` fall-through would
81
+ // be 600 notifications a minute.
82
+ //
83
+ // BOTH paths, and the mint deliberately shares the submit bucket: a bot must fetch a
84
+ // ticket before it can post one, so rationing the mint rations the attempts. Written as
85
+ // an exact match plus the one child, never `startsWith('/api/contact')` — that is one
86
+ // sibling vocabulary away from swallowing a future `/api/contacts`, which is this
87
+ // file's recorded near-miss failure.
88
+ if (path === '/api/contact' || path === '/api/contact/ticket')
89
+ return [TIERS.contact];
90
+ // ---- "Set up AI": POST /api/ai/runtime/install (LOCAL ONLY) ----
91
+ // FOLLOWING THE TOKEN: no model dollars and no GitHub quota — it runs `npm install` of the pinned
92
+ // AI runtime, ~110 MB of the user's own bandwidth and minutes of a child process. The work is
93
+ // single-flight inside ai/runtime.ts (a second POST joins the running download rather than
94
+ // starting one), so the bucket only bounds how many SSE relays a loop can open. `sync` is the
95
+ // matching shape: "minutes of network work, started by a click". EXACT `===`: `/api/ai` is a
96
+ // family other routes may join, and none of them should inherit this decision.
97
+ if (path === '/api/ai/runtime/install')
98
+ return [TIERS.sync];
99
+ // ---- Workspace CRUD: `read`, RECORDED rather than inherited ----
100
+ //
101
+ // The six surviving routes (list / create / rename+reassign / delete / move-one-repo-in /
102
+ // PUT :id/pending-mute) are pure local database work: a membership upsert on (account_id,
103
+ // repo_id), a rename, a re-home of repos and reviewer rows to Default, and — since the Pending
104
+ // mute — one boolean UPDATE on `workspaces` plus a bounded delete/insert over
105
+ // `pending_muted_repos` inside the workspace's own membership. Nothing behind any of them calls
106
+ // GitHub and nothing behind them calls an LLM — assignment still writes ONE membership row and
107
+ // nothing else (`repos.inbox_watch` is dropped; there is no second visibility column left to
108
+ // set, and the mute is not one: it changes no screen's population, only whether a row may claim
109
+ // the reader's turn), and the sync scheduler picks the repo up on its own cron rather than on
110
+ // this request. So `read` is the same
111
+ // answer the fall-through at the bottom of this function would give, and it is spelled out
112
+ // anyway: the two documented mistakes in this file were both a route whose tier was never
113
+ // decided, only inherited. An explicit line makes the next reader's question "is this still
114
+ // true?" instead of "did anyone look?".
115
+ //
116
+ // Written as an exact match plus a `/`-terminated prefix, NOT a bare
117
+ // `startsWith('/api/workspaces')` and emphatically not `startsWith('/api/workspace')`:
118
+ // the `/api/workspace-metrics` family is a SIBLING vocabulary, and a loose prefix over two
119
+ // sibling vocabularies is precisely how one silently swallows the other.
120
+ if (path === '/api/workspaces' || path.startsWith('/api/workspaces/'))
121
+ return [TIERS.read];
122
+ // ---- Per-account settings writes: `read`, RECORDED rather than inherited ----
123
+ //
124
+ // POST /api/me/large-pr-threshold — the large-PR flag's code-churn threshold. One schema-
125
+ // validated UPDATE of one integer column on one row. No GitHub call, no model call, no fold:
126
+ // following the token, there is no token. `read` is the same answer the fall-through at the
127
+ // bottom of this function would give, and it is spelled out anyway for this file's stated
128
+ // reason — the two documented mistakes here were both a route whose tier was never decided,
129
+ // only inherited. Its sibling POST /api/me/benchmark-consent still takes the fall-through
130
+ // because it DOES have a leg worth thinking about (a fire-and-forget rollup on opt-in), and
131
+ // pinning it here would hide that.
132
+ //
133
+ // EXACT `===`. `/api/me` is a family with an export, an account deletion and a data-subject
134
+ // GET in it, so a `startsWith('/api/me/')` would sweep all of them into one bucket — and a
135
+ // `startsWith('/api/me')` is worse still: it ALSO matches `/api/mention-candidates`, a live
136
+ // sibling vocabulary. That is this file's recorded near-miss failure, one character away.
137
+ if (path === '/api/me/large-pr-threshold')
138
+ return [TIERS.read];
139
+ // PUT /api/me/blast-radius-config — the blast-radius reading settings. Same shape and same
140
+ // answer as the threshold above: one schema-validated UPDATE of one column on one row, no
141
+ // GitHub call and no model call. Recorded rather than inherited for the same reason, and with
142
+ // the same EXACT `===` for the same near-miss.
143
+ if (path === '/api/me/blast-radius-config')
144
+ return [TIERS.read];
145
+ // PUT /api/me/my-turn-settings — the My Turn settings (types shown, their order, the Do next
146
+ // weights). The same shape and the same answer again: one validated UPDATE of one column on one
147
+ // row plus an in-process cache drop, no GitHub call and no model call. Recorded, with the same
148
+ // EXACT `===` for the same near-miss.
149
+ if (path === '/api/me/my-turn-settings')
150
+ return [TIERS.read];
151
+ // PUT / DELETE /api/my-turn/dismissals/:kind/:id — dismiss or bring back one My Turn entry. One
152
+ // ownership read and one upsert/delete on a tiny table; no GitHub call, no model call. Recorded
153
+ // rather than inherited, and spelled with the `/`-terminated prefix: `/api/my-turn` itself (the
154
+ // inbox GET) is a sibling that must keep its own decision.
155
+ if (path.startsWith('/api/my-turn/dismissals/'))
156
+ return [TIERS.read];
157
+ // ---- Bot Tuning Advisor (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
158
+ // "Follow the token": most advisor routes are DB-only reads/writes, but the generic
159
+ // /api/pro/ branch below puts every mutating POST on the 20/min AI bucket — which would
160
+ // throttle a plain `dismiss` like an LLM call, while the three GitHub-writing outputs
161
+ // (config-pr / manifest-pr / issue) genuinely spend GitHub quota and the one LLM route
162
+ // (refine) genuinely spends model dollars. Each path family is spelled exactly and pinned
163
+ // in rate-limit.test.ts. Staying under /api/pro/ keeps the automatic 402 entitlement gate.
164
+ if (path.startsWith('/api/pro/advisor/')) {
165
+ if (mutating && path.endsWith('/config-pr'))
166
+ return [TIERS.githubWrite];
167
+ if (mutating && path.endsWith('/manifest-pr'))
168
+ return [TIERS.githubWrite];
169
+ if (mutating && path.endsWith('/issue'))
170
+ return [TIERS.githubWrite];
171
+ if (mutating && path.endsWith('/refine'))
172
+ return [TIERS.ai, TIERS.aiHourly];
173
+ // findings = a full-corpus aggregation (the compare-tier precedent); discovery and
174
+ // preview = a few GitHub contents-API reads (the pr-detail hydration precedent —
175
+ // preview is the config-pr dry-run: it fetches the config files but writes nothing).
176
+ if (!mutating && path.endsWith('/findings'))
177
+ return [TIERS.search, TIERS.read];
178
+ if (!mutating && path.endsWith('/discovery'))
179
+ return [TIERS.prDetail, TIERS.read];
180
+ if (mutating && path.endsWith('/preview'))
181
+ return [TIERS.prDetail, TIERS.read];
182
+ // dismiss / profiles / config-events / recommendations / brief / effect: DB-only.
183
+ return [TIERS.read];
184
+ }
185
+ // ---- Period reports (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
186
+ // The Insights → Reports family is two free GETs and two spending POSTs under ONE path prefix,
187
+ // which is exactly the shape the catch-all below gets wrong in both directions: it tiers on the
188
+ // VERB, so `GET …/reports` (a list of stored rows — no model call, no GitHub) would inherit the
189
+ // 600/min blanket read bucket by accident rather than by decision, while a future GET that
190
+ // happened to be a POST would land on `ai` for free. Spelling the family out here also means
191
+ // the next reader sees the four routes and their costs together:
192
+ // POST …/:periodKey/generate an Anthropic completion (Haiku OR Sonnet — the one route in the
193
+ // app whose per-call price the USER picks) plus, on a first run,
194
+ // a bounded 8-period metrics backfill: ~20 indexed scans.
195
+ // POST …/:periodKey/chat one grounded Anthropic completion per turn.
196
+ // GET …/reports stored rows + a bounded per-period coverage read. DB only.
197
+ // GET …/reports/:periodKey one stored row + ONE metric-vector recompute for the staleness
198
+ // flag, PLUS the "By workspace" axis (C4): 2 windows ×
199
+ // N workspaces × getPeriodMetrics — the one cost in the family
200
+ // that MULTIPLIES by workspace count, which is the exact shape
201
+ // that put the deleted /api/workspace-metrics/compare on the
202
+ // 60/min `search` bucket. Same tier here, same reason. Still DB
203
+ // only — it must NEVER grow a generation leg (the
204
+ // `GET /api/pro/prs/:id/annotations` precedent).
205
+ // Anchored on the exact family prefix so no sibling `/api/pro/insights/*` route is swept in.
206
+ if (path === '/api/pro/insights/reports' || path.startsWith('/api/pro/insights/reports/')) {
207
+ if (mutating && (path.endsWith('/generate') || path.endsWith('/chat'))) {
208
+ return [TIERS.ai, TIERS.aiHourly];
209
+ }
210
+ // The one-report GET carries the by-workspace axis (N × getPeriodMetrics per window) —
211
+ // `search`, not the 600/min blanket. Anchored at both ends so ONLY `…/reports/:periodKey`
212
+ // matches: the list root and a GET spelling of `…/generate` stay on the plain read bucket.
213
+ if (/^\/api\/pro\/insights\/reports\/[^/]+$/.test(path))
214
+ return [TIERS.search, TIERS.read];
215
+ return [TIERS.read];
216
+ }
217
+ // GET /api/pro/insights/month-to-date — the OPEN calendar month, computed live on every read
218
+ // (Reports → Month grain). DECIDED, not inherited: it does NOT match the `…/insights/reports`
219
+ // family block above, so without this line it would fall through to the `/api/pro/` catch-all's
220
+ // GET→`read` branch and sit on the 600/min blanket bucket. It costs SIX indexed scans per call
221
+ // — the headline metric vector, two `repos.createdAt` coverage reads, the lane breakdown, and
222
+ // both sides of the elapsed-slice comparison — which is the one-report GET's shape of cost, on a
223
+ // surface the SPA re-reads whenever the pane regains focus (an open period's figures move, so
224
+ // there is nothing to cache for long). Same 60/min `search` bucket, same argument. It NEVER
225
+ // generates and NEVER writes a row, so it must never move to the AI tier either.
226
+ if (!mutating && path === '/api/pro/insights/month-to-date') {
227
+ return [TIERS.search, TIERS.read];
228
+ }
229
+ // ---- 1:1 prep (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
230
+ // GET /api/pro/insights/person/:userId — the person-period vector (plan P4.2). DECIDED, not
231
+ // inherited from the catch-all's GET→read branch: per request it runs the lane resolver, the
232
+ // one-fold first-human-review scan and the request→review scan (two capped candidate walks)
233
+ // plus ~10 person-scoped aggregates — the reports-GET shape of cost (this event loop and this
234
+ // database, no GitHub, no Anthropic), so it borrows the same 60/min `search` bucket rather
235
+ // than the 600/min blanket. Fired once per 1:1 section mount / period switch. The narration
236
+ // POST is NOT here — it rides the /api/pro/synthesis pair above (kind 'person'). The
237
+ // `?evidence=1` variant (the People report) is the same event-loop shape — ~8 additional
238
+ // capped `ORDER BY … LIMIT` selects on the same fold — so it sits on the same tier.
239
+ if (!mutating && path.startsWith('/api/pro/insights/person/')) {
240
+ return [TIERS.search, TIERS.read];
241
+ }
242
+ // GET /api/bot-authoring — the authoring-automation vector, the bot-shaped twin of the person
243
+ // route above and DECIDED for the identical reason: it runs the lane resolver, a capped
244
+ // merged-PR walk (AUTOMATION_MERGED_PR_CAP) whose ids then travel as bind parameters into two
245
+ // more scans, plus ~6 windowed aggregates. It is CORE and free, so it never reaches the
246
+ // /api/pro/ AI catch-all — without this line it would silently inherit the 600/min blanket
247
+ // `read` bucket, which is the wrong answer for a fold this size (the "follow the token" rule:
248
+ // 'DB-only' is not the same as 'cheap').
249
+ if (!mutating && path === '/api/bot-authoring')
250
+ return [TIERS.search, TIERS.read];
251
+ // ---- Bot behaviour depth (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
252
+ // GET /api/pro/bot-behaviour — the workspace behaviour rollup, MOVED from core's
253
+ // /api/bot-behaviour behind the `botDepth` entitlement (plan P0.2). DECIDED, not inherited from
254
+ // the /api/pro/ catch-all's GET→read branch: per request it computes every bot's TTFR /
255
+ // follow-up / week×hour heatmap plus trends, anomalies, overlap and the ML label fold over the
256
+ // whole window — the same shape of cost as the flagging/volume family
257
+ // beside it (this process's event loop and this database, not GitHub quota and not Anthropic),
258
+ // so it borrows the same 60/min `search` bucket rather than the 600/min blanket one. (In core
259
+ // it sat on the blanket `read` fall-through — that was the inherited tier this file warns
260
+ // about, corrected in the move.) The SPA fires it once per Bots view on the 5-min sync cadence.
261
+ if (!mutating && path === '/api/pro/bot-behaviour')
262
+ return [TIERS.search, TIERS.read];
263
+ // ---- The peer-cohort benchmark (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
264
+ // GET /api/pro/bot-benchmark — the plugin's projection of the bundled distribution artifact.
265
+ //
266
+ // FOLLOWING THE TOKEN, NOT THE SENTENCE. "This route is DB-only" is not even true in the
267
+ // reassuring direction here: it touches no database, no GitHub and no Anthropic, which makes the
268
+ // naive read "it is free, put it on the 600/min blanket". It is not free, and the cost sits
269
+ // somewhere unusual — THE RESPONSE BODY IS THE WORK. MEASURED against today's corpus: the
270
+ // manifest alone is 16 KB, a 24-cell request of refused cells is 44 KB, and a FITTED cell is
271
+ // ~10 KB, so a capped request at full panel size is ~260 KB of JSON.stringify on the event loop
272
+ // and straight onto the socket (no @fastify/compress is registered in this backend, so the JSON
273
+ // size IS the wire size for whatever the edge does not gzip). At 600/min that is ~150 MB/min
274
+ // from one account; at 60/min it is 15 MB/min — the same order as the flagging/volume/behaviour
275
+ // reads beside it, and the same shape of cost, so the same bucket by the identical argument.
276
+ //
277
+ // The CELL CAP bounds the work per request; this tier bounds the request count. Complementary,
278
+ // neither a substitute — and the cap is part of THIS decision, not a separate nicety: without
279
+ // it a single request can name every cell in the artifact and the arithmetic above is wrong by
280
+ // an order of magnitude.
281
+ //
282
+ // EXACT `===`, never a prefix. The family has one member and must keep one: a `startsWith`
283
+ // on `/api/pro/bot-b…` is one sibling away from swallowing `/api/pro/bot-behaviour`, which is
284
+ // the near-miss failure this file has already recorded three times.
285
+ //
286
+ // ⚠ STANDING INVARIANT, WRITTEN BEFORE THE FOLD EXISTS: if this route ever grows a customer-side
287
+ // PLACEMENT leg (a per-repo × vendor read of the tenant's own PRs), the tier must be re-decided
288
+ // IN THAT SAME CHANGE. `/api/attention` moved its tier only after the fold had landed.
289
+ //
290
+ // THE FOLD LANDED, AND IT LANDED AS A SIBLING PATH RATHER THAN A LEG — so the invariant is
291
+ // honoured by the line BELOW, decided here, in the same change. Both stay EXACT `===` matches
292
+ // (see the near-miss note above); an `===` on the parent cannot swallow the child, and a
293
+ // `startsWith` on either would be one sibling away from the failure this file has recorded three
294
+ // times.
295
+ if (!mutating && path === '/api/pro/bot-benchmark')
296
+ return [TIERS.search, TIERS.read];
297
+ // GET /api/pro/bot-benchmark/placement — the CUSTOMER side: the caller's own (repository ×
298
+ // vendor) metric vector, folded over the corpus's populations, placed in an activity band and
299
+ // compared against the cell.
300
+ //
301
+ // FOLLOWING THE TOKEN. Its parent route's cost is its RESPONSE BODY; this one's cost is a
302
+ // DATABASE FOLD, and the two arguments are unrelated — which is exactly why the tier is spelled
303
+ // separately instead of being read across from the line above. Per request it reads, for up to
304
+ // BOT_BENCHMARK_MAX_PLACEMENT_REPOS (12) repositories: one windowed merged-PR count, up to
305
+ // `walk_budget` (≤150) pull-request rows, and every review thread, review comment, PR comment
306
+ // and review body hanging off them — then folds thirteen metrics per (repository × automated
307
+ // reviewer) pair. That is the same shape of cost as the flagging/volume/bot-behaviour family
308
+ // beside it (this process's event loop and this database, not GitHub quota and not Anthropic),
309
+ // so it takes the same 60/min `search` bucket by the identical argument, and emphatically NOT
310
+ // the `/api/pro/` catch-all's 600/min GET→read branch, which is what it would silently inherit.
311
+ //
312
+ // The REPOSITORY CAP bounds the work per request; this tier bounds the request count.
313
+ // Complementary, neither a substitute — and the cap is part of THIS decision, not a separate
314
+ // nicety: without it one request folds an entire workspace and the arithmetic above is wrong by
315
+ // whatever a tenant's repository count happens to be.
316
+ //
317
+ // It must NEVER grow a GENERATION leg (the `GET /api/pro/prs/:id/annotations` precedent) — there
318
+ // is no model anywhere in this feature and its anomaly sentences are templated.
319
+ if (!mutating && path === '/api/pro/bot-benchmark/placement') {
320
+ return [TIERS.search, TIERS.read];
321
+ }
322
+ // ---- Synthesis (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
323
+ // ONE endpoint, two verbs, two costs (plan P2.1):
324
+ // POST /api/pro/synthesis the generate path — one Haiku call behind a payload-hash $0 cache,
325
+ // plus the input fold below. The catch-all's `generates` branch
326
+ // would land it on `ai` anyway; spelled here so the family's two
327
+ // tiers are DECIDED together (this file's failure mode is a tier
328
+ // nobody re-examined).
329
+ // GET /api/pro/synthesis the free cached read — but it recomputes the payload hash from
330
+ // `getSynthesisInput`, i.e. it re-runs the drill-down's own
331
+ // population fold (the flagging kind's ≤50k-row label scan, the
332
+ // volume kind's merged-PR walk). That is the flagging/volume/
333
+ // bot-behaviour shape of cost — this process's event loop and this
334
+ // database, not GitHub and not Anthropic — so it borrows the same
335
+ // 60/min `search` bucket, NOT the catch-all's 600/min GET→read
336
+ // branch. It must NEVER grow a generation leg (the
337
+ // `GET /api/pro/prs/:id/annotations` precedent).
338
+ if (path === '/api/pro/synthesis') {
339
+ if (mutating)
340
+ return [TIERS.ai, TIERS.aiHourly];
341
+ return [TIERS.search, TIERS.read];
342
+ }
343
+ // ---- The work plan (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
344
+ // ONE endpoint, two verbs, two costs — the synthesis shape exactly, and tiered by copying it:
345
+ // POST /api/pro/work-plan the generate path — one Haiku call behind a payload-hash $0 cache,
346
+ // plus the evidence fold below. The catch-all's `generates` branch
347
+ // would land it on `ai` anyway; spelled here so the family's two
348
+ // tiers are DECIDED together (this file's failure mode is a tier
349
+ // nobody re-examined).
350
+ // GET /api/pro/work-plan the free cached read — but it re-runs the whole `getWorkPlan`
351
+ // evidence fold to recompute the payload hash for the `stale` probe,
352
+ // and that fold reaches getWorkspaceInsights (the stalled-review /
353
+ // untouched-thread / reviewer-load / merge / update_branch cards).
354
+ // That is the synthesis / bot-behaviour shape of cost — this
355
+ // process's event loop and this database, not GitHub quota and not
356
+ // Anthropic — so it takes the same 60/min `search` bucket, NOT the
357
+ // catch-all's 600/min GET→read branch. The narration now decorates
358
+ // the Pending board, so this GET fires whenever that board is
359
+ // mounted. It must NEVER grow a generation leg (the
360
+ // `GET /api/pro/prs/:id/annotations` precedent).
361
+ if (path === '/api/pro/work-plan') {
362
+ if (mutating)
363
+ return [TIERS.ai, TIERS.aiHourly];
364
+ return [TIERS.search, TIERS.read];
365
+ }
366
+ // ---- Chronology pointers (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
367
+ // The work plan's shape and costs exactly: POST is one Haiku call behind a payload-hash $0 cache
368
+ // (the `ai` pair); the free GET re-runs the whole Chronology fold (`getFlowCourts` — the lane
369
+ // resolver plus chunked action scans over every merge in the window) plus the exemplar text
370
+ // reads to recompute the hash for its `stale` probe, so it takes `search`, never the
371
+ // catch-all's 600/min GET→read branch. The GET must never grow a generation leg.
372
+ if (path === '/api/pro/flow-pointers') {
373
+ if (mutating)
374
+ return [TIERS.ai, TIERS.aiHourly];
375
+ return [TIERS.search, TIERS.read];
376
+ }
377
+ // ---- The Slack digest family (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
378
+ // TWO paths under one prefix with OPPOSITE costs, which is exactly the shape the catch-all gets
379
+ // wrong: it tiers on the VERB, so both of these are mutating and both would land on the 20/min
380
+ // `ai` bucket — right for one of them and wrong for the other.
381
+ //
382
+ // PUT/DELETE
383
+ // /api/pro/slack/target ONE workspace's delivery config (plugin 0030; narrowed to a
384
+ // single workspace when the picker was removed). A `?workspace=`
385
+ // resolution plus ONE upsert or ONE delete on one small table. No
386
+ // model, no GitHub: following the token, there is no token.
387
+ // `read`, decided — it is a SETTINGS WRITE, and the file already
388
+ // records that a settings write is a settings write at every depth
389
+ // (the `/api/pro/settings/workspace` note below). ⚠ THE PATH IS
390
+ // SINGULAR NOW. The plural `/targets` is DELETED; a stale client
391
+ // calling it 404s, which is the catch-all's problem, not a tier's.
392
+ // POST /api/pro/slack/test "Send test" — and it is NOT a probe. It runs the SAME billed
393
+ // path the cron does: `refreshSprintReport` (one Haiku call unless
394
+ // the payload hash is unchanged) plus the account-wide repo-digest
395
+ // refresh, then an outbound POST to a user-supplied host. It stays
396
+ // on `ai` + `aiHourly` — where the catch-all would have put it
397
+ // anyway, spelled here so the family's two tiers are DECIDED
398
+ // together rather than one of them being inherited. ⚠ It takes ONE
399
+ // `?workspace=` and must never fan out over the whole selection: a
400
+ // test button that generated twelve reports would be the most
401
+ // expensive control in the app.
402
+ //
403
+ // Anchored on exact paths. A `startsWith('/api/pro/slack')` would be one sibling away from
404
+ // swallowing whatever this family grows next — the near-miss failure this file has recorded
405
+ // three times.
406
+ if (path === '/api/pro/slack/target')
407
+ return [TIERS.read];
408
+ if (mutating && path === '/api/pro/slack/test')
409
+ return [TIERS.ai, TIERS.aiHourly];
410
+ // ---- Jira API reads (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
411
+ // GET /api/pro/jira/fields and GET /api/pro/prs/:id/jira-ticket each make ONE outbound call to
412
+ // the customer's own Jira with the workspace's saved token — a THIRD-PARTY quota (Jira Cloud rate
413
+ // limits per user) and up to a 10s wait on this process. Following the token: it is theirs, not
414
+ // ours, so the catch-all's 600/min GET→read branch is wrong; they take the 60/min `search`
415
+ // bucket, the "one upstream call per request" tier. Anchored on exact paths (the ticket route by
416
+ // a both-ends regex), so a sibling `/api/pro/prs/:id/*` route is not swept in.
417
+ if (!mutating && path === '/api/pro/jira/fields')
418
+ return [TIERS.search, TIERS.read];
419
+ if (!mutating && /^\/api\/pro\/prs\/[^/]+\/jira-ticket$/.test(path)) {
420
+ return [TIERS.search, TIERS.read];
421
+ }
422
+ // ---- AI generation ----
423
+ // ⚠ RE-DECIDED, not inherited: `POST /api/pro/prs/:id/annotations/run` now spends GITHUB quota
424
+ // as well as model tokens — one PR_DETAIL_QUERY per uncached PR for the anchor hunks
425
+ // (sync/hydrate-detail.ts) on top of the two-sha compares. It stays on `ai` (20/min), which is
426
+ // TIGHTER than `prDetail`'s 60/min, so the GitHub exposure is already bounded more
427
+ // conservatively than a plain `GET /api/prs/:id`. Nothing to change — but the decision is
428
+ // recorded, because this file's failure mode is a tier nobody re-examined.
429
+ // The PAIRED `GET /api/pro/prs/:id/annotations` must STAY on `read`: it is a pure DB read fired
430
+ // on every PR open, and it must never grow a hydration leg (see the prDetail note above for
431
+ // what that cost the first time).
432
+ //
433
+ // Two shapes: the Pro plugin's /api/pro/* generators, and the Claude Review
434
+ // family, which for back-compat kept the pre-plugin paths (/api/prs/:id/
435
+ // claude-review, /api/claude-reviews/*, /api/claude-findings/*) and so does NOT
436
+ // sit under /api/pro/. Both are matched here.
437
+ // Claude Review CHAT (`/api/claude-reviews/:reviewId/chat`), matched EXPLICITLY so a later edit
438
+ // to the family rules below cannot quietly move it: every POST is one billed agent turn (`ai`),
439
+ // the GET is a stored-history read.
440
+ if (/^\/api\/claude-reviews\/[^/]+\/chat$/.test(path)) {
441
+ return mutating ? [TIERS.ai, TIERS.aiHourly] : [TIERS.read];
442
+ }
443
+ const isClaudeReviewPath = path.includes('/claude-review') ||
444
+ path.startsWith('/api/claude-reviews') ||
445
+ path.startsWith('/api/claude-findings');
446
+ if (isClaudeReviewPath || path.startsWith('/api/pro/')) {
447
+ // A GET is a cheap read of a stored result (poll status, load history) — only
448
+ // the generation verbs spend. `/refresh` and `/ask` are POSTs; so are the
449
+ // review/fix starts. The one GET that generates is a `…/stream` SSE subscribe,
450
+ // which attaches to an already-started run, so it stays a read.
451
+ // ⚠ THE SETTINGS EXEMPTIONS ARE A LIST, NOT A SUFFIX. `endsWith('/settings')` covered
452
+ // `PUT /api/pro/settings` and nothing beneath it, so `PUT /api/pro/settings/workspace` — the
453
+ // per-workspace sprint cadence, one DB upsert, no model and no GitHub quota — would have landed
454
+ // on the 20/min AI bucket purely because of where its path sits. A settings write is a settings
455
+ // write at every depth; the "follow the token" rule cuts BOTH ways.
456
+ const isSettingsWrite = path === '/api/pro/settings' || path.startsWith('/api/pro/settings/');
457
+ const generates = mutating &&
458
+ !path.endsWith('/cancel') &&
459
+ !path.endsWith('/key') &&
460
+ !path.endsWith('/budget') &&
461
+ // The Open PRs table's batched latest-run read: a POST only because a list of ids does not
462
+ // fit a query string. One DB query, no model, no GitHub — so `read`, exact path only.
463
+ path !== '/api/claude-review/states' &&
464
+ !isSettingsWrite;
465
+ if (generates)
466
+ return [TIERS.ai, TIERS.aiHourly];
467
+ return [TIERS.read];
468
+ }
469
+ // ---- GitHub-quota spenders ----
470
+ if (path.startsWith('/api/repos/search'))
471
+ return [TIERS.search, TIERS.read];
472
+ if (path === '/api/repos/suggested')
473
+ return [TIERS.search, TIERS.read];
474
+ // (GET /api/workspace-metrics/compare — the old N × getWorkspaceMetrics comparison whose cost
475
+ // multiplied by workspace count, which is what earned it the `search` tier — was DELETED with
476
+ // the Compare rail entry (C4). The multiplication survives in the Reports "By workspace" axis,
477
+ // which is why `GET /api/pro/insights/reports/:periodKey` sits on `search` in the family block
478
+ // above. Its two siblings `/api/workspace-metrics` and `/api/workspace-metrics/detail` remain
479
+ // ordinary single-window reads on the `read` fall-through — they do not multiply by anything.)
480
+ // (GET /api/bot-severity — the standalone ML-severity rollup — was REMOVED on the C7 cut list;
481
+ // its `search` tier entry left with it.)
482
+ // GET /api/bot-analytics/vendor/<key>/comments — the per-bot comments drill-down: up to 3000
483
+ // comment BODIES per source plus a three-way ml_comment_labels join, per request. Same shape
484
+ // of cost as the rollup above (this process, not GitHub and not Anthropic), so the same
485
+ // 60/min bucket. Its `/prs` sibling stays on `read` DELIBERATELY — that list is PR metadata
486
+ // only, no bodies. Anchored at both ends so the sibling cannot be swept in.
487
+ if (!mutating && /^\/api\/bot-analytics\/vendor\/[^/]+\/comments$/.test(path)) {
488
+ return [TIERS.search, TIERS.read];
489
+ }
490
+ // GET /api/bot-analytics/flagging — the "what the bots are flagging" drill-down. Per request it
491
+ // re-runs the strip's WHOLE 50k-row label scan (the page offset is a JS slice over a fold of a
492
+ // JSON column that no portable SQL predicate can express, so there is no cheaper page to fetch)
493
+ // or the window's whole thread scan plus the ±3-line clustering, and then hydrates a page of
494
+ // comment BODIES on top. Strictly more work than the vendor-comments route beside it, and an
495
+ // IntersectionObserver fires it repeatedly as the user scrolls — so `search`, never the 600/min
496
+ // blanket. Anchored at both ends like its neighbour: `/api/bot-analytics` itself and
497
+ // `/api/bot-analytics/bot-only-prs` are ordinary reads and must keep falling through.
498
+ if (!mutating && /^\/api\/bot-analytics\/flagging$/.test(path)) {
499
+ return [TIERS.search, TIERS.read];
500
+ }
501
+ // GET /api/bot-analytics/volume[/prs|/scatter] — the bot-comment-volume family. Per request each
502
+ // walks EVERY merged PR in the window (up to 5000) plus three grouped comment counts over that
503
+ // population, then folds the whole thing in JS; the `/prs` page offset is a slice over that
504
+ // fold, so a cursor walk re-runs the scan per page and an IntersectionObserver fires it again
505
+ // on every scroll. Same shape of cost as its two neighbours above — this process's event loop,
506
+ // not GitHub quota and not Anthropic — so the same 60/min bucket rather than the 600/min
507
+ // blanket one. Anchored at BOTH ends with the sub-paths spelled out, so `/api/bot-analytics`
508
+ // itself and `/api/bot-analytics/bot-only-prs` keep falling through to `read`.
509
+ if (!mutating && /^\/api\/bot-analytics\/volume(\/(prs|scatter))?$/.test(path)) {
510
+ return [TIERS.search, TIERS.read];
511
+ }
512
+ // GET /api/prs/<id>/ml-labels — the per-PR badge index. Two indexed reads over
513
+ // ml_comment_labels, no model call, no GitHub. `read` is right, and it is RECORDED here rather
514
+ // than inherited from the fall-through: it sits inside the /api/prs/<id>/ family whose other
515
+ // members are GitHub-hydrating, so the next person to widen `prGithubGet` should have to see
516
+ // that this one was decided, not defaulted.
517
+ if (!mutating && /^\/api\/prs\/\d+\/ml-labels$/.test(path))
518
+ return [TIERS.read];
519
+ // GET /api/ml-status — the enrichment worker's live state, POLLED every few seconds while a
520
+ // sync round is open so the progress UI can represent the model pass. Its in-memory half is
521
+ // free, but its backlog half is a bot-set resolve plus three unlabelled-count joins PER
522
+ // WORKSPACE, so it belongs in the same 60/min `search` bucket, not the blanket read one. The
523
+ // route caches the scan for a few seconds precisely because the tier bounds request COUNT and
524
+ // not the work each request does — the two are complementary, neither is a substitute.
525
+ if (path.startsWith('/api/ml-status'))
526
+ return [TIERS.search, TIERS.read];
527
+ // GET /api/daily-brief — the free counts strip (plan P3.1/P3.3). No GitHub, no LLM, but one
528
+ // call folds the consolidated feed + the insights cards + the resolve backlog, and `?rollup=1`
529
+ // multiplies that by the account's workspace count — the compare-route cost shape, so the same
530
+ // DELIBERATE 60/min `search` bucket rather than the 600/min blanket. The route's 5-min TTL
531
+ // cache bounds the work per request; the tier bounds request COUNT — complementary, neither a
532
+ // substitute (the ml-status rationale verbatim).
533
+ if (!mutating && path.startsWith('/api/daily-brief'))
534
+ return [TIERS.search, TIERS.read];
535
+ // GET /api/attention — the Pending board. RE-TIERED off the 600/min blanket `read` when it
536
+ // absorbed the ranked head: one request now runs `getWorkspaceInsights` (including the
537
+ // maintained-repo resolution, which the merge-card emitter makes effectively unconditional)
538
+ // AND `rankWorkPlan` on top of it — the approvals fold, the untouched-thread group-by and a
539
+ // narrow PR select. Same cost shape as the daily brief above, same 60/min bucket.
540
+ // ⚠ "This route is DB-only" is exactly the sentence this file exists to distrust: the fold
541
+ // moved into it, so the tier moves with it. The board mounts on navigation and refetches on a
542
+ // 5-minute cadence, so request COUNT is genuinely bounded and 60/min is comfortable.
543
+ if (!mutating && path === '/api/attention')
544
+ return [TIERS.search, TIERS.read];
545
+ // POST /api/attention/liveness — the board's batched GitHub sweep. The sibling GET above is
546
+ // DB-only; this one is not, and the tier follows the TOKEN, not the URL family. One call is two
547
+ // `nodes(ids:)` GraphQL requests (measured 2 points, ~7s wall for a 90-PR board), so it is
548
+ // exactly the `POST /api/reactions/lookup` shape — a mutating VERB carrying a target LIST with a
549
+ // GET's cost — and takes the same 60/min `prDetail` bucket the other client-driven
550
+ // GitHub-hydrating reads do. The SPA sweeps on mount, on focus and on a 60s interval, which is
551
+ // ~2/min: 60 leaves room for several tabs while a script in a loop hits the wall in seconds.
552
+ //
553
+ // Spelled as an EXACT match, ABOVE the `if (mutating)` block, for the reason this file has now
554
+ // recorded four times: it would otherwise fall through to the 600/min blanket `read` bucket,
555
+ // and it is NOT in the `hitsGithub` alternation below because that regex is anchored to
556
+ // `/api/prs/\d+/` — adding `liveness` there would match nothing and merely look like coverage.
557
+ // (`githubWrite` would also be the wrong bucket regardless: this route writes nothing to
558
+ // GitHub, it reads.)
559
+ if (mutating && path === '/api/attention/liveness')
560
+ return [TIERS.prDetail, TIERS.read];
561
+ // GET /api/flow-findings — the Chronology tab (PAID `periodReports`, deterministic, no model and
562
+ // no GitHub — the route 402s before it touches the DB). The TIER is unaffected by that gate and
563
+ // must stay where it is: an entitled account still runs the whole fold, and the tier answers
564
+ // "what does one accepted request cost", never "who may make it". DECIDED, not inherited: "this
565
+ // route is DB-only" is the sentence this file exists to distrust, and one call here runs the
566
+ // lane resolver, the shared first-human-review fold (two
567
+ // capped candidate walks), a thread-path scan, an in-window review scan, a merged-PR walk plus
568
+ // its approving reviews, an open-PR snapshot with the approvals fold, and the round-trip
569
+ // comment join. That is strictly the daily-brief / attention shape of cost — this event loop
570
+ // and this database — so it takes the same 60/min `search` bucket rather than the 600/min
571
+ // blanket. The tab mounts on navigation and refetches on the sync cadence, so request COUNT is
572
+ // genuinely bounded; the engine's own scan caps bound the work per request. Complementary,
573
+ // neither a substitute.
574
+ if (!mutating && path === '/api/flow-findings')
575
+ return [TIERS.search, TIERS.read];
576
+ // ---- Emoji reactions: BOTH routes reach GitHub, and NEITHER lives under /api/prs/<id>/ ----
577
+ //
578
+ // Spelled as two EXACT string matches, above the mutating block, for the reason this file has
579
+ // now recorded three times: a tier that is inherited rather than decided is silently wrong,
580
+ // and both of these would otherwise land on the 600/min blanket `read` bucket. They are NOT
581
+ // in the `hitsGithub` alternation below because that regex is anchored to `/api/prs/\d+/` —
582
+ // adding `reactions` there would match nothing and merely look like coverage.
583
+ //
584
+ // POST /api/reactions/lookup — the batched read. A mutating VERB with GET-shaped cost (the
585
+ // `POST /api/prs/:id/refresh` precedent): it carries a target LIST in a body and converts
586
+ // 1:1 into a GraphQL `nodes(ids:)` call, so it belongs on the same 60/min bucket as the
587
+ // other client-driven GitHub-hydrating reads. Client-side batching means one screenful of
588
+ // comments is ONE request, so 60/min is far above any human use — while a script looping
589
+ // it is exactly what drains the tenant's 5,000 points/hour.
590
+ // POST /api/reactions — the toggle. A GitHub write like any other: `github_write`.
591
+ //
592
+ // Exact matches rather than a `/api/reactions` prefix so the two cannot be swept into one
593
+ // bucket if a third route is ever added under this family.
594
+ if (path === '/api/reactions/lookup')
595
+ return [TIERS.prDetail, TIERS.read];
596
+ if (path === '/api/reactions')
597
+ return [TIERS.githubWrite];
598
+ // GET /api/prs/<id> plus the sub-routes that ALSO hydrate live from GitHub rather than reading
599
+ // already-synced rows:
600
+ // `/merge-options` repo merge config + mergeability + the merge-queue GraphQL probe
601
+ // — up to five upstream calls, strictly MORE than the detail route
602
+ // `/files` the Changes tab's patches (one REST page per `?page=` click)
603
+ // `/files/content` one file's raw text at the PR head or merge base (a gap marker's
604
+ // "Show N hidden lines") — 1 REST call, +2 for the merge base cold
605
+ // `/files/diff` "Load full diff" for a file GitHub sent no patch for: both sides
606
+ // of the file plus the merge base — up to 4 REST calls
607
+ // `/checks/<jobId>/logs` an Actions job log: a REST call that 302s to a signed blob we
608
+ // then range-fetch, so up to two upstream requests and megabytes
609
+ // `/suggested-reviewers` CODEOWNERS via REST (`ghRestGetContentRaw`) + the team-history
610
+ // GraphQL probe. TTL-cached per (account, repo), but a COLD cache
611
+ // spends quota, and a cache that only sometimes saves you is not a
612
+ // reason to sit on the blanket bucket.
613
+ //
614
+ // This used to be anchored to the bare id "because the sub-routes are DB-only reads" — true when
615
+ // written, quietly false later, which left the most GitHub-expensive GETs in the PR family on the
616
+ // 600/min blanket bucket. The earlier fix of that mistake then repeated it in miniature by
617
+ // asserting in this very comment that `suggested-reviewers` "really is DB-only": it calls
618
+ // `getAccessToken` on line 35 of github/reviewer-suggest.ts. When in doubt, follow the token.
619
+ // Genuinely DB-only and correctly left on `read`: `/bot-behaviour`, `/bot-dedup`,
620
+ // `/mention-candidates`, `/claude-review` (retrieval), `/annotations` (the cached GET).
621
+ const prGithubGet = /^\/api\/prs\/\d+(\/(merge-options|files|files\/content|files\/diff|suggested-reviewers|checks\/[^/]+\/logs))?$/;
622
+ if (!mutating && prGithubGet.test(path)) {
623
+ return [TIERS.prDetail, TIERS.read];
624
+ }
625
+ // GET /api/threads/<id> hydrates the same way.
626
+ if (!mutating && /^\/api\/threads\/\d+$/.test(path))
627
+ return [TIERS.prDetail, TIERS.read];
628
+ // POST /api/prs/<id>/refresh — the PrDetail live poll (~every 5s while a PR pane is
629
+ // visible) + its manual Refresh button. A mutating VERB (POST so the cross-origin guard
630
+ // applies) with GET-shaped cost: a quiet tick is one free conditional REST 304, a changed
631
+ // or floor-forced one is a syncOnePr walk + a hydration bust — the same GitHub-cost
632
+ // profile as GET /api/prs/:id, so it shares the `prDetail` bucket (the 5s cadence is
633
+ // 12/min of the 60, leaving room for a second tab plus the user's own detail traffic).
634
+ // NOT `githubWrite` (it writes nothing to GitHub) and NOT the blanket `read` (it can
635
+ // spend a GraphQL walk per call) — spelled EXACTLY, per this file's twice-documented
636
+ // failure mode of a tier inherited instead of decided.
637
+ if (mutating && /^\/api\/prs\/\d+\/refresh$/.test(path)) {
638
+ return [TIERS.prDetail, TIERS.read];
639
+ }
640
+ if (mutating && (path === '/api/repos' || /^\/api\/repos\/\d+\/sync$/.test(path))) {
641
+ return [TIERS.sync];
642
+ }
643
+ // ---- The merge-conflict resolver (CORE / free, BOTH MODES) ----
644
+ // SEVEN paths under one prefix with FOUR different costs, which is the exact shape this file
645
+ // gets wrong when it guesses. ⚠ SEVEN ROUTES, FOUR BUCKETS — the edit route reuses the file
646
+ // read's `[search, read]` verbatim, and three reads share `[read]`, so the counts do not track
647
+ // each other and adding a route does not add a cost. Each is anchored at BOTH ends: the segment
648
+ // is `conflicts` (PLURAL — `conflict` matches nothing, the `comments`/`/comment` trap one
649
+ // vocabulary over), and `/conflicts` is a PREFIX of `/conflicts/commit` and of
650
+ // `/conflicts/edit`, so an unanchored match would hand the open, the commit, the edit, the file
651
+ // read and the stream one bucket — right for one of the five it would swallow.
652
+ // ⚠ Do NOT add `conflicts` to the `hitsGithub` alternation below: that regex is unanchored
653
+ // and would swallow all seven.
654
+ //
655
+ // POST …/conflicts starts a CLONE (ensureClone + two fetches + merge-tree):
656
+ // disk, CPU and GitHub bandwidth, minutes cold. Same shape as
657
+ // POST /api/repos, so the same 20/min `sync` bucket — NOT
658
+ // githubWrite (it writes nothing upstream), emphatically not
659
+ // the 600/min blanket.
660
+ // POST …/conflicts/commit a real `git push`. githubWrite, like every other write here.
661
+ // POST …/conflicts/edit the ONE route in the app that accepts typed file content: a
662
+ // string scan and a Map write, no git and no GitHub. Cheap — and
663
+ // the 600/min blanket is still WRONG for it, because what it
664
+ // spends is not CPU, it is RETAINED MEMORY in a process every
665
+ // tenant shares (a per-session budget caps the total, but the
666
+ // bucket is what stops somebody arriving at it). 60/min is more
667
+ // saves than any reader makes; same reasoning as the file read
668
+ // beside it, different resource.
669
+ // GET …/conflicts/files/N an in-memory read whose RESPONSE BODY IS THE WORK — a
670
+ // three-pane model of one source file. The bot-benchmark
671
+ // argument verbatim, so the same 60/min bucket.
672
+ // GET …/conflicts the manifest: a Map lookup. `read`, RECORDED not inherited.
673
+ // ⚠ IT IS ALSO THE RECOVERY CHANNEL. When a proxy cuts the SSE
674
+ // stream at its request cap, the SPA polls this instead
675
+ // (hooks/useConflictSession.ts), every 2s while a job runs and
676
+ // every 8s otherwise — 30/min worst case against 600, for one
677
+ // overlay a reader has open. Do NOT tighten this bucket to fit the
678
+ // Map lookup it looks like: it is what stops a commit's outcome
679
+ // becoming unknowable.
680
+ // GET …/conflicts/stream an SSE subscribe onto an already-started job — a read.
681
+ // DELETE …/conflicts a Map.delete. `read` — DECIDED, not inherited from the POST
682
+ // line, which it would otherwise match.
683
+ if (method === 'POST' && /^\/api\/prs\/\d+\/conflicts$/.test(path))
684
+ return [TIERS.sync];
685
+ if (method === 'DELETE' && /^\/api\/prs\/\d+\/conflicts$/.test(path))
686
+ return [TIERS.read];
687
+ if (mutating && /^\/api\/prs\/\d+\/conflicts\/commit$/.test(path))
688
+ return [TIERS.githubWrite];
689
+ if (mutating && /^\/api\/prs\/\d+\/conflicts\/edit$/.test(path))
690
+ return [TIERS.search, TIERS.read];
691
+ if (!mutating && /^\/api\/prs\/\d+\/conflicts\/files\/\d+$/.test(path)) {
692
+ return [TIERS.search, TIERS.read];
693
+ }
694
+ if (!mutating && /^\/api\/prs\/\d+\/conflicts(\/stream)?$/.test(path))
695
+ return [TIERS.read];
696
+ if (mutating) {
697
+ // Writes that reach GitHub: thread replies/resolves, PR and inline review comments,
698
+ // approvals, closes and reopens, CI re-runs, reviewer requests, merges, merge-queue
699
+ // enqueue/dequeue, auto-merge arm/disarm, branch updates, bulk bot-thread resolves.
700
+ //
701
+ // Every route is listed by its EXACT path segment, because the alternation is matched
702
+ // against the real path and nothing else corrects a spelling mistake here — the failure
703
+ // is silent. This list previously read `comments`/`reviews` (plural) while the routes are
704
+ // `/comment` and `/review-comment`, and omitted `close`, `ci/rerun` and
705
+ // `request-reviewers` outright, so FIVE GitHub-write routes sat on the 600/min blanket
706
+ // `read` bucket. `review-comment` is now the most upstream-expensive write in the family:
707
+ // it fetches the head sha and the file patches to anchor the line, posts, then resyncs the
708
+ // PR and forces a fresh PR_DETAIL_QUERY so the new thread is visible immediately.
709
+ //
710
+ // `merge-queue` and `auto-merge` are spelled out even though `merge` would prefix-match
711
+ // the first (the alternation has no trailing anchor): relying on that coincidence is how
712
+ // a later tightening of the regex silently drops two GitHub-write routes onto the
713
+ // 600/min read tier. `review-comment` is spelled out for the same reason rather than
714
+ // leaning on a `reviews?` prefix. `reopen` is listed for the same reason and one more:
715
+ // `close` does NOT prefix-match it, so there is no coincidence to fall back on at all —
716
+ // omit it and the reopen route silently takes the 600/min `read` bucket while spending a
717
+ // GitHub REST write per call. Arming auto-merge is a DB write, but disarm/arm both
718
+ // re-check mergeability against GitHub and the watcher merges on the account's quota, so
719
+ // it belongs in the same bucket as an explicit merge.
720
+ //
721
+ // Deliberately NOT here: `/dismiss` and `/mark-viewed` (pure local bookkeeping).
722
+ const hitsGithub = path.startsWith('/api/threads/') ||
723
+ path.startsWith('/api/bot-threads/') ||
724
+ /^\/api\/prs\/\d+\/(review-comment|comments?|approve|request-changes|close|reopen|ci\/rerun|request-reviewers|merge-queue|merge|auto-merge|update-branch|resolve-bot-threads|reviews)/.test(path);
725
+ if (hitsGithub)
726
+ return [TIERS.githubWrite];
727
+ }
728
+ // Everything else, deliberately including the two cross-account GETs `GET /api/auto-merge`
729
+ // (the armed-intent list) and `GET /api/branch-status` (default-branch health): both are pure
730
+ // DB reads off already-synced rows — no GitHub call, no LLM — so the blanket 600/min backstop
731
+ // is the right bucket. If either ever grows a live GitHub fetch it must move to
732
+ // `search`/`prDetail` like the other hydrating reads.
733
+ //
734
+ // The five workspace-scoped CONTENT routes (`/api/timeline`, `/api/activity`,
735
+ // `/api/activity/feed`, `/api/open-prs`, `/api/branch-status`) stay here too. Taking
736
+ // `?workspace=` did not change what they cost: the resolver's membership repair
737
+ // (`ensureRepoMemberships`, a write on essentially every GET) is a local anti-join plus an
738
+ // `ON CONFLICT DO NOTHING` insert of at most the account's unassigned repos, which is cheaper
739
+ // than the feed query it precedes and reaches nothing upstream.
740
+ //
741
+ // `GET /api/bot-reviewers` (its lazy pass classifies any actor with a footprint in the workspace
742
+ // and no stored row) and the two `DELETE /api/bot-reviewers/:userId/{judgement,identity}` resets
743
+ // (each re-derives in the same request) all run `classifyReviewer`, and they stay on `read`.
744
+ //
745
+ // ⚠ THAT USED TO BE A KNOWN COMPROMISE AND IS NOW SIMPLY CORRECT — kept written down because the
746
+ // reasoning is what would have to be re-derived. `classifyReviewer`'s last resort for the
747
+ // medium-confidence band was an opt-in Haiku tie-break (`review/llm.ts cheapComplete`), i.e. a
748
+ // real LLM leg on a `read`-tier route; it was tolerated because the setting gating it defaulted
749
+ // OFF. That code PATH NO LONGER EXISTS — the setting and the tie-break were deleted together, and
750
+ // `sync/reviewer-classify.ts` no longer imports the LLM seam at all — so these three routes are
751
+ // now purely DB-bound and `read` is the right bucket rather than the acceptable one. The rule
752
+ // that produced the earlier caution still stands: FOLLOW THE TOKEN. If anything ever puts a
753
+ // model call or a GitHub fetch back inside `classifyReviewer`, these three need a tier in the
754
+ // SAME change — not as a follow-up.
755
+ return [TIERS.read];
756
+ }
757
+ // ---- Storage ----
758
+ //
759
+ // One map per tier, keyed by "<tier>:<account-or-ip>". Swept on an interval so a
760
+ // churn of distinct IPs cannot grow it without bound (which would itself be the DoS
761
+ // this plugin exists to prevent). MAX_KEYS is a hard backstop: past it, the whole map
762
+ // is dropped rather than allowed to consume memory — losing a window of accounting is
763
+ // strictly better than an OOM.
764
+ const MAX_KEYS = 50_000;
765
+ const buckets = new Map();
766
+ function sweep(now) {
767
+ if (buckets.size > MAX_KEYS) {
768
+ buckets.clear();
769
+ return;
770
+ }
771
+ for (const [k, b] of buckets) {
772
+ // Anything older than the longest window is dead regardless of tier.
773
+ if (now - b.start > HOUR * 2)
774
+ buckets.delete(k);
775
+ }
776
+ }
777
+ /** Consume one token. Returns null when allowed, or the seconds to wait when not. */
778
+ function consume(key, t, now) {
779
+ const existing = buckets.get(key);
780
+ if (!existing || now - existing.start >= t.windowMs) {
781
+ buckets.set(key, { start: now, count: 1 });
782
+ return null;
783
+ }
784
+ existing.count += 1;
785
+ if (existing.count <= t.limit)
786
+ return null;
787
+ return Math.max(1, Math.ceil((existing.start + t.windowMs - now) / 1000));
788
+ }
789
+ /**
790
+ * The bucket key. Prefer the account id: it is the thing that spends money, it
791
+ * survives a changing IP, and it cannot be spoofed. Fall back to the client IP for
792
+ * unauthenticated routes (sign-in, webhooks).
793
+ *
794
+ * NOTE the IP fallback only works in cloud because `trustProxy` is enabled there
795
+ * (app.ts) — Railway terminates TLS and forwards, so without it every request would
796
+ * share the proxy's IP and collapse into one bucket. In local mode there is no proxy
797
+ * and exactly one account, so this is always the account key in practice.
798
+ */
799
+ function keyFor(req, t) {
800
+ const accountId = req.account?.id;
801
+ return accountId != null ? `${t.name}:a${accountId}` : `${t.name}:i${req.ip}`;
802
+ }
803
+ /**
804
+ * Registers the limiter. MUST be registered AFTER registerAccountContext so
805
+ * `req.account` is resolved and buckets can be keyed per tenant rather than per IP.
806
+ *
807
+ * RATE_LIMIT_DISABLED=true turns it off entirely (an escape hatch for a first-run
808
+ * bulk backfill, a load test, or a self-hosted single-user instance that would
809
+ * rather not have the ceiling).
810
+ */
811
+ export function registerRateLimit(app) {
812
+ if (process.env.RATE_LIMIT_DISABLED === 'true') {
813
+ app.log.warn('rate limiting DISABLED (RATE_LIMIT_DISABLED=true)');
814
+ return;
815
+ }
816
+ let lastSweep = 0;
817
+ app.addHook('onRequest', async (req, reply) => {
818
+ const path = req.url.split('?')[0] ?? req.url;
819
+ if (!path.startsWith('/api/'))
820
+ return;
821
+ // The health probe must never 429 — Railway uses it to decide the deploy is up.
822
+ if (path === '/api/health')
823
+ return;
824
+ const now = Date.now();
825
+ if (now - lastSweep > 5 * MINUTE) {
826
+ lastSweep = now;
827
+ sweep(now);
828
+ }
829
+ for (const t of tierFor(req.method.toUpperCase(), path)) {
830
+ const retryAfter = consume(keyFor(req, t), t, now);
831
+ if (retryAfter != null) {
832
+ req.log.warn({ path, method: req.method, tier: t.name, limit: t.limit, accountId: req.account?.id }, 'rate limit exceeded');
833
+ reply.header('Retry-After', String(retryAfter));
834
+ // ⚠ THE UNIT COMES FROM THE TIER, NOT FROM A LITERAL. The generic arm used to be
835
+ // hard-coded `${t.limit}/min`, which was true of every tier at the time and stopped
836
+ // being true the moment one took an hour window: the `contact` bucket is 5 an HOUR
837
+ // and the message said "limit 5/min … try again in 3597s", which is a self-
838
+ // contradicting sentence in front of a member of the public.
839
+ const per = t.windowMs >= HOUR ? 'hour' : 'minute';
840
+ await reply.code(429).send({
841
+ error: 'TooManyRequests',
842
+ message: t.name === 'ai' || t.name === 'ai_hourly'
843
+ ? `Too many AI requests (limit ${t.limit} per ${per}). Try again in ${retryAfter}s.`
844
+ : `Too many requests (limit ${t.limit} per ${per}). Try again in ${retryAfter}s.`,
845
+ retryAfter,
846
+ });
847
+ return;
848
+ }
849
+ }
850
+ });
851
+ app.log.info({ mode: config.deploymentMode, ai: TIERS.ai.limit, read: TIERS.read.limit }, 'rate limiting active');
852
+ }
853
+ /** Exposed for the unit test. */
854
+ export const __testing = { TIERS, tierFor, consume, buckets };
855
+ //# sourceMappingURL=rate-limit.js.map