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.
- package/LICENSE +110 -0
- package/README.md +133 -0
- package/dist/ai/runtime.js +535 -0
- package/dist/api/plugins/auth.js +169 -0
- package/dist/api/plugins/error-handler.js +114 -0
- package/dist/api/plugins/rate-limit.js +855 -0
- package/dist/api/plugins/security.js +321 -0
- package/dist/api/routes/activity.js +103 -0
- package/dist/api/routes/ai-runtime.js +67 -0
- package/dist/api/routes/auth.js +194 -0
- package/dist/api/routes/billing.js +209 -0
- package/dist/api/routes/bot-triage.js +1289 -0
- package/dist/api/routes/conflicts.js +730 -0
- package/dist/api/routes/contact.js +318 -0
- package/dist/api/routes/daily-brief.js +58 -0
- package/dist/api/routes/flow.js +71 -0
- package/dist/api/routes/health.js +7 -0
- package/dist/api/routes/insights.js +200 -0
- package/dist/api/routes/me.js +458 -0
- package/dist/api/routes/mentions.js +22 -0
- package/dist/api/routes/mergers.js +8 -0
- package/dist/api/routes/ml-labels.js +109 -0
- package/dist/api/routes/open-prs.js +35 -0
- package/dist/api/routes/prs.js +1633 -0
- package/dist/api/routes/reactions.js +217 -0
- package/dist/api/routes/repos.js +563 -0
- package/dist/api/routes/search.js +41 -0
- package/dist/api/routes/threads.js +118 -0
- package/dist/api/routes/timeline.js +150 -0
- package/dist/api/routes/users.js +67 -0
- package/dist/api/routes/webhooks.js +181 -0
- package/dist/api/routes/workspaces.js +322 -0
- package/dist/app.js +274 -0
- package/dist/ascii.js +19 -0
- package/dist/auth/account.js +482 -0
- package/dist/auth/crypto.js +46 -0
- package/dist/bot-triage/resolve.js +59 -0
- package/dist/cli.js +388 -0
- package/dist/coding/activity.js +80 -0
- package/dist/coding/agent.js +288 -0
- package/dist/coding/ai-fix/auto-fix.js +212 -0
- package/dist/coding/ai-fix/manager.js +370 -0
- package/dist/coding/ai-fix/persist.js +213 -0
- package/dist/coding/ai-fix/pr-context.js +58 -0
- package/dist/coding/ai-fix/prompts.js +138 -0
- package/dist/coding/ai-fix/review-seed.js +357 -0
- package/dist/coding/ai-fix/routes.js +425 -0
- package/dist/coding/git-ops.js +321 -0
- package/dist/coding/git.js +184 -0
- package/dist/coding/merge.js +262 -0
- package/dist/coding/safe-path.js +75 -0
- package/dist/coding/schema.js +43 -0
- package/dist/config.js +648 -0
- package/dist/conflict/diff.js +304 -0
- package/dist/conflict/git.js +118 -0
- package/dist/conflict/hash.js +73 -0
- package/dist/conflict/janitor.js +218 -0
- package/dist/conflict/land.js +675 -0
- package/dist/conflict/model-types.js +2 -0
- package/dist/conflict/model.js +1081 -0
- package/dist/conflict/parse.js +142 -0
- package/dist/conflict/seam.js +136 -0
- package/dist/conflict/session.js +532 -0
- package/dist/conflict/suggestion.js +398 -0
- package/dist/conflict/tokens.js +72 -0
- package/dist/data-dir.js +112 -0
- package/dist/db/actor-lanes.js +213 -0
- package/dist/db/automation-ids.js +97 -0
- package/dist/db/automation-output.js +349 -0
- package/dist/db/blast-radius-query.js +36 -0
- package/dist/db/blast-radius.js +226 -0
- package/dist/db/bot-dormancy.js +115 -0
- package/dist/db/bot-overlap.js +579 -0
- package/dist/db/bot-volume.js +722 -0
- package/dist/db/bot-window.js +35 -0
- package/dist/db/branch-queries.js +310 -0
- package/dist/db/change-shape.js +243 -0
- package/dist/db/changepoint.js +75 -0
- package/dist/db/cleanup.js +36 -0
- package/dist/db/client.js +102 -0
- package/dist/db/code-loc.js +388 -0
- package/dist/db/credits.js +79 -0
- package/dist/db/daily-brief.js +406 -0
- package/dist/db/dependency-cards.js +145 -0
- package/dist/db/erase-account.js +255 -0
- package/dist/db/export-account.js +189 -0
- package/dist/db/failing-checks.js +124 -0
- package/dist/db/file-coupling.js +197 -0
- package/dist/db/flow-detail.js +661 -0
- package/dist/db/flow-pointers.js +197 -0
- package/dist/db/flow-settings.js +67 -0
- package/dist/db/forecast.js +114 -0
- package/dist/db/line-overlap.js +81 -0
- package/dist/db/merge-queue.js +189 -0
- package/dist/db/migrate.js +17 -0
- package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
- package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
- package/dist/db/migrations/0002_famous_sersi.sql +9 -0
- package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
- package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
- package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
- package/dist/db/migrations/0006_cute_violations.sql +45 -0
- package/dist/db/migrations/0007_dark_anthem.sql +2 -0
- package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
- package/dist/db/migrations/0009_lean_bodies.sql +13 -0
- package/dist/db/migrations/0010_lean_bodies_nullable.sql +56 -0
- package/dist/db/migrations/0011_pr_diff_size.sql +10 -0
- package/dist/db/migrations/0012_findings_included_default.sql +7 -0
- package/dist/db/migrations/0013_review_routing.sql +9 -0
- package/dist/db/migrations/0014_account_last_active.sql +6 -0
- package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
- package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
- package/dist/db/migrations/0017_account_display_name.sql +7 -0
- package/dist/db/migrations/0018_finding_comment_kind.sql +7 -0
- package/dist/db/migrations/0019_finding_file_in_diff.sql +7 -0
- package/dist/db/migrations/0020_claude_review_cost_telemetry.sql +11 -0
- package/dist/db/migrations/0021_account_feed_last_seen.sql +7 -0
- package/dist/db/migrations/0022_ci_status_events.sql +21 -0
- package/dist/db/migrations/0023_ai_usage.sql +21 -0
- package/dist/db/migrations/0024_pr_head_ref_name.sql +6 -0
- package/dist/db/migrations/0025_account_billing.sql +7 -0
- package/dist/db/migrations/0026_ai_credit_allowance.sql +5 -0
- package/dist/db/migrations/0027_user_github_type.sql +6 -0
- package/dist/db/migrations/0028_bot_review_classification.sql +21 -0
- package/dist/db/migrations/0029_bot_mute_rules.sql +19 -0
- package/dist/db/migrations/0030_teams.sql +32 -0
- package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
- package/dist/db/migrations/0032_benchmark_contributions.sql +29 -0
- package/dist/db/migrations/0033_thread_addressed_confidence.sql +9 -0
- package/dist/db/migrations/0034_thread_resolved_at.sql +6 -0
- package/dist/db/migrations/0035_pr_first_review_requested_at.sql +5 -0
- package/dist/db/migrations/0036_search_index.sql +25 -0
- package/dist/db/migrations/0037_author_indexes.sql +15 -0
- package/dist/db/migrations/0038_auto_merge_requests.sql +37 -0
- package/dist/db/migrations/0039_branch_status.sql +44 -0
- package/dist/db/migrations/0040_pr_review_decision.sql +10 -0
- package/dist/db/migrations/0041_branch_commit_detail.sql +38 -0
- package/dist/db/migrations/0042_repo_reviewers.sql +176 -0
- package/dist/db/migrations/0043_account_reviewers.sql +131 -0
- package/dist/db/migrations/0044_workspaces.sql +138 -0
- package/dist/db/migrations/0045_workspace_reviewers.sql +180 -0
- package/dist/db/migrations/0046_drop_inbox_watch.sql +25 -0
- package/dist/db/migrations/0047_ml_comment_labels.sql +40 -0
- package/dist/db/migrations/0048_ml_vendor_severity.sql +31 -0
- package/dist/db/migrations/0049_seat_pricing_repo_description.sql +21 -0
- package/dist/db/migrations/0050_users_app_slug.sql +12 -0
- package/dist/db/migrations/0051_auto_merge_queue.sql +17 -0
- package/dist/db/migrations/0052_trunk_ci_status_events.sql +43 -0
- package/dist/db/migrations/0053_automation_roles.sql +79 -0
- package/dist/db/migrations/0054_automation_vendor_kinds.sql +514 -0
- package/dist/db/migrations/0055_auto_merge_phase.sql +12 -0
- package/dist/db/migrations/0056_pr_mentions.sql +22 -0
- package/dist/db/migrations/0057_large_pr_threshold.sql +14 -0
- package/dist/db/migrations/0058_pending_mute.sql +53 -0
- package/dist/db/migrations/0059_merge_queue_state.sql +33 -0
- package/dist/db/migrations/0060_drop_my_turn_dismissals.sql +33 -0
- package/dist/db/migrations/0061_auto_merge_durable_anchors.sql +27 -0
- package/dist/db/migrations/0062_blast_radius_config.sql +21 -0
- package/dist/db/migrations/0063_repo_file_coupling.sql +33 -0
- package/dist/db/migrations/0064_pr_content_kind.sql +21 -0
- package/dist/db/migrations/0065_workspace_flow_settings.sql +13 -0
- package/dist/db/migrations/0066_review_request_events.sql +31 -0
- package/dist/db/migrations/0067_pr_security_signals.sql +86 -0
- package/dist/db/migrations/0068_my_turn_settings.sql +18 -0
- package/dist/db/migrations/0069_my_turn_dismissals.sql +41 -0
- package/dist/db/migrations/0070_claude_review_follow_up_ticket.sql +22 -0
- package/dist/db/migrations/0071_claude_review_trigger.sql +11 -0
- package/dist/db/migrations/0072_commit_message_headline.sql +13 -0
- package/dist/db/migrations/0073_claude_review_chat.sql +31 -0
- package/dist/db/migrations/0074_adopt_agentic_tables.sql +98 -0
- package/dist/db/migrations/0075_review_lens_drop_learnings.sql +15 -0
- package/dist/db/migrations/0076_claude_review_threads.sql +17 -0
- package/dist/db/migrations/0077_claude_review_ci_failures.sql +10 -0
- package/dist/db/migrations/0078_ai_fix_change_report.sql +17 -0
- package/dist/db/migrations/0079_claude_finding_story.sql +16 -0
- package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
- package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
- package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
- package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
- package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
- package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
- package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
- package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
- package/dist/db/migrations/meta/_journal.json +566 -0
- package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
- package/dist/db/migrations-pg/0001_dizzy_mockingbird.sql +3 -0
- package/dist/db/migrations-pg/0002_glorious_ikaris.sql +4 -0
- package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
- package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
- package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
- package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
- package/dist/db/migrations-pg/0007_same_human_torch.sql +2 -0
- package/dist/db/migrations-pg/0008_slimy_toxin.sql +1 -0
- package/dist/db/migrations-pg/0009_fuzzy_jubilee.sql +4 -0
- package/dist/db/migrations-pg/0010_late_hammerhead.sql +1 -0
- package/dist/db/migrations-pg/0011_happy_leech.sql +16 -0
- package/dist/db/migrations-pg/0012_woozy_captain_midlands.sql +16 -0
- package/dist/db/migrations-pg/0013_yielding_nicolaos.sql +1 -0
- package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
- package/dist/db/migrations-pg/0015_aspiring_black_tom.sql +1 -0
- package/dist/db/migrations-pg/0016_cute_ghost_rider.sql +30 -0
- package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
- package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
- package/dist/db/migrations-pg/0019_benchmark_contributions.sql +22 -0
- package/dist/db/migrations-pg/0020_majestic_edwin_jarvis.sql +3 -0
- package/dist/db/migrations-pg/0021_green_salo.sql +1 -0
- package/dist/db/migrations-pg/0022_wise_baron_strucker.sql +1 -0
- package/dist/db/migrations-pg/0023_search_index.sql +19 -0
- package/dist/db/migrations-pg/0024_author_indexes.sql +9 -0
- package/dist/db/migrations-pg/0025_auto_merge_requests.sql +26 -0
- package/dist/db/migrations-pg/0026_branch_status.sql +32 -0
- package/dist/db/migrations-pg/0027_pr_review_decision.sql +5 -0
- package/dist/db/migrations-pg/0028_branch_commit_detail.sql +9 -0
- package/dist/db/migrations-pg/0029_repo_reviewers.sql +127 -0
- package/dist/db/migrations-pg/0030_account_reviewers.sql +77 -0
- package/dist/db/migrations-pg/0031_workspaces.sql +131 -0
- package/dist/db/migrations-pg/0032_workspace_reviewers.sql +165 -0
- package/dist/db/migrations-pg/0033_drop_inbox_watch.sql +14 -0
- package/dist/db/migrations-pg/0034_ml_comment_labels.sql +33 -0
- package/dist/db/migrations-pg/0035_ml_vendor_severity.sql +16 -0
- package/dist/db/migrations-pg/0036_seat_pricing_repo_description.sql +12 -0
- package/dist/db/migrations-pg/0037_users_app_slug.sql +9 -0
- package/dist/db/migrations-pg/0038_auto_merge_queue.sql +10 -0
- package/dist/db/migrations-pg/0039_trunk_ci_status_events.sql +27 -0
- package/dist/db/migrations-pg/0040_automation_roles.sql +63 -0
- package/dist/db/migrations-pg/0041_automation_vendor_kinds.sql +488 -0
- package/dist/db/migrations-pg/0042_auto_merge_phase.sql +8 -0
- package/dist/db/migrations-pg/0043_pr_mentions.sql +19 -0
- package/dist/db/migrations-pg/0044_large_pr_threshold.sql +6 -0
- package/dist/db/migrations-pg/0045_pending_mute.sql +26 -0
- package/dist/db/migrations-pg/0046_merge_queue_state.sql +18 -0
- package/dist/db/migrations-pg/0047_drop_my_turn_dismissals.sql +21 -0
- package/dist/db/migrations-pg/0048_auto_merge_durable_anchors.sql +27 -0
- package/dist/db/migrations-pg/0049_blast_radius_config.sql +8 -0
- package/dist/db/migrations-pg/0050_repo_file_coupling.sql +13 -0
- package/dist/db/migrations-pg/0051_pr_content_kind.sql +6 -0
- package/dist/db/migrations-pg/0052_workspace_flow_settings.sql +4 -0
- package/dist/db/migrations-pg/0053_review_request_events.sql +18 -0
- package/dist/db/migrations-pg/0054_pr_security_signals.sql +59 -0
- package/dist/db/migrations-pg/0055_my_turn_settings.sql +6 -0
- package/dist/db/migrations-pg/0056_my_turn_dismissals.sql +21 -0
- package/dist/db/migrations-pg/0057_claude_review_follow_up_ticket.sql +8 -0
- package/dist/db/migrations-pg/0058_claude_review_trigger.sql +4 -0
- package/dist/db/migrations-pg/0059_commit_message_headline.sql +3 -0
- package/dist/db/migrations-pg/0060_claude_review_chat.sql +24 -0
- package/dist/db/migrations-pg/0061_adopt_agentic_tables.sql +79 -0
- package/dist/db/migrations-pg/0062_review_lens_drop_learnings.sql +5 -0
- package/dist/db/migrations-pg/0063_claude_review_threads.sql +7 -0
- package/dist/db/migrations-pg/0064_claude_review_ci_failures.sql +4 -0
- package/dist/db/migrations-pg/0065_ai_fix_change_report.sql +9 -0
- package/dist/db/migrations-pg/0066_claude_finding_story.sql +7 -0
- package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
- package/dist/db/migrations-pg/meta/0001_snapshot.json +2143 -0
- package/dist/db/migrations-pg/meta/0002_snapshot.json +2170 -0
- package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
- package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
- package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
- package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
- package/dist/db/migrations-pg/meta/0007_snapshot.json +2219 -0
- package/dist/db/migrations-pg/meta/0008_snapshot.json +2226 -0
- package/dist/db/migrations-pg/meta/0009_snapshot.json +2250 -0
- package/dist/db/migrations-pg/meta/0010_snapshot.json +2256 -0
- package/dist/db/migrations-pg/meta/0011_snapshot.json +2412 -0
- package/dist/db/migrations-pg/meta/0012_snapshot.json +2528 -0
- package/dist/db/migrations-pg/meta/0013_snapshot.json +2534 -0
- package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
- package/dist/db/migrations-pg/meta/0015_snapshot.json +2553 -0
- package/dist/db/migrations-pg/meta/0016_snapshot.json +2774 -0
- package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
- package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
- package/dist/db/migrations-pg/meta/0020_snapshot.json +3228 -0
- package/dist/db/migrations-pg/meta/0021_snapshot.json +3234 -0
- package/dist/db/migrations-pg/meta/0022_snapshot.json +3240 -0
- package/dist/db/migrations-pg/meta/_journal.json +475 -0
- package/dist/db/ml-labels.js +1666 -0
- package/dist/db/my-turn-dismissals.js +343 -0
- package/dist/db/my-turn-settings.js +34 -0
- package/dist/db/pending-classify.js +46 -0
- package/dist/db/pending-mute.js +171 -0
- package/dist/db/pending-tabs.js +238 -0
- package/dist/db/period-metrics.js +820 -0
- package/dist/db/person-period.js +763 -0
- package/dist/db/pr-intervals.js +930 -0
- package/dist/db/pr-liveness.js +240 -0
- package/dist/db/pr-mentions.js +279 -0
- package/dist/db/pr-merge-queue-stamp.js +91 -0
- package/dist/db/queries.js +14631 -0
- package/dist/db/reaction-targets.js +94 -0
- package/dist/db/repo-activity.js +171 -0
- package/dist/db/retention.js +138 -0
- package/dist/db/review-threads-for-review.js +240 -0
- package/dist/db/run-migrations.js +24 -0
- package/dist/db/schema.pg.js +1220 -0
- package/dist/db/schema.sqlite.js +1801 -0
- package/dist/db/search.js +317 -0
- package/dist/db/security-alerts.js +218 -0
- package/dist/db/synthesis-input.js +565 -0
- package/dist/db/triage.js +320 -0
- package/dist/db/usage.js +63 -0
- package/dist/db/work-plan.js +1086 -0
- package/dist/db/working-hours.js +161 -0
- package/dist/feed/my-turn.js +122 -0
- package/dist/github/actions-logs.js +397 -0
- package/dist/github/auth.js +86 -0
- package/dist/github/branch-queries.js +229 -0
- package/dist/github/client.js +454 -0
- package/dist/github/codeowners.js +221 -0
- package/dist/github/commit-checks.js +105 -0
- package/dist/github/compare.js +110 -0
- package/dist/github/diff-anchor.js +227 -0
- package/dist/github/mutations.js +706 -0
- package/dist/github/pr-file-content.js +254 -0
- package/dist/github/pr-liveness.js +161 -0
- package/dist/github/queries.js +875 -0
- package/dist/github/rate-budget.js +129 -0
- package/dist/github/reactions.js +161 -0
- package/dist/github/reviewer-suggest.js +104 -0
- package/dist/github/team-reviewers.js +96 -0
- package/dist/index.js +184 -0
- package/dist/merge/auto-merge-runner.js +960 -0
- package/dist/ml/severity-client.js +218 -0
- package/dist/pr/detail-enricher.js +26 -0
- package/dist/pro/bind.js +302 -0
- package/dist/pro/contract.js +37 -0
- package/dist/pro/migrate.js +64 -0
- package/dist/review/agent-context.js +127 -0
- package/dist/review/agent.js +385 -0
- package/dist/review/agentic.js +59 -0
- package/dist/review/auth.js +94 -0
- package/dist/review/chat-agent.js +179 -0
- package/dist/review/claude-review/ai-ready.js +16 -0
- package/dist/review/claude-review/auto-settings.js +139 -0
- package/dist/review/claude-review/auto.js +244 -0
- package/dist/review/claude-review/chat.js +489 -0
- package/dist/review/claude-review/ci-failures.js +350 -0
- package/dist/review/claude-review/follow-up.js +310 -0
- package/dist/review/claude-review/manager.js +686 -0
- package/dist/review/claude-review/persist.js +996 -0
- package/dist/review/claude-review/prompts.js +357 -0
- package/dist/review/claude-review/routes.js +518 -0
- package/dist/review/claude-review/routing.js +61 -0
- package/dist/review/claude-review/specialists.js +190 -0
- package/dist/review/claude-review/threads.js +265 -0
- package/dist/review/claude-review/ticket.js +282 -0
- package/dist/review/clone-hygiene.js +380 -0
- package/dist/review/clone-manager.js +367 -0
- package/dist/review/llm.js +113 -0
- package/dist/review/model-options.js +48 -0
- package/dist/review/plugin-providers.js +12 -0
- package/dist/review/post-review.js +216 -0
- package/dist/review/post-seam.js +161 -0
- package/dist/review/prepare.js +168 -0
- package/dist/review/pricing.js +17 -0
- package/dist/review/schema.js +156 -0
- package/dist/review/submit-map.js +41 -0
- package/dist/review/usage.js +90 -0
- package/dist/shared/claude-review.js +335 -0
- package/dist/shared/conflict-fold.js +151 -0
- package/dist/shared/conflicts.js +30 -0
- package/dist/shared/flow-settings.js +191 -0
- package/dist/shared/index.js +8 -0
- package/dist/shared/my-turn-settings.js +238 -0
- package/dist/shared/pending-rules.js +281 -0
- package/dist/shared/product.js +54 -0
- package/dist/shared/types.js +896 -0
- package/dist/status.js +386 -0
- package/dist/sync/adaptive.js +185 -0
- package/dist/sync/app-attribution.js +66 -0
- package/dist/sync/auth-notices.js +36 -0
- package/dist/sync/backfill-ci-history.js +315 -0
- package/dist/sync/backfill-commit-headlines.js +136 -0
- package/dist/sync/backfill-pr-security.js +175 -0
- package/dist/sync/backfill-review-requests.js +97 -0
- package/dist/sync/benchmark-rollup.js +50 -0
- package/dist/sync/bot-detection.js +309 -0
- package/dist/sync/bot-resolution-markers.js +30 -0
- package/dist/sync/branch-status.js +634 -0
- package/dist/sync/classify-change-shape.js +150 -0
- package/dist/sync/commit-files.js +74 -0
- package/dist/sync/derive-thread-state.js +99 -0
- package/dist/sync/drain-review-threads.js +212 -0
- package/dist/sync/hydrate-detail.js +482 -0
- package/dist/sync/mention-scan.js +89 -0
- package/dist/sync/ml-enrichment.js +478 -0
- package/dist/sync/pr-change-signal.js +96 -0
- package/dist/sync/pr-liveness-sweep.js +139 -0
- package/dist/sync/pr-settle.js +381 -0
- package/dist/sync/refresh-pr.js +163 -0
- package/dist/sync/reparse-vendor-badges.js +495 -0
- package/dist/sync/resync-after-write.js +280 -0
- package/dist/sync/review-fingerprint.js +117 -0
- package/dist/sync/reviewer-behavior.js +103 -0
- package/dist/sync/reviewer-classify.js +503 -0
- package/dist/sync/routing-files.js +97 -0
- package/dist/sync/scheduled-jobs.js +12 -0
- package/dist/sync/scheduler.js +159 -0
- package/dist/sync/security-detect.js +521 -0
- package/dist/sync/sync-manager.js +750 -0
- package/dist/sync/sync-one-pr.js +205 -0
- package/dist/sync/sync-repo.js +400 -0
- package/dist/sync/unsettled-prs.js +506 -0
- package/dist/sync/upsert.js +1190 -0
- package/package.json +56 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/AiFixTab-CQP6PT5u.js +2 -0
- package/public/assets/ClaudeReviewTab-DQJ-58ZA.js +8 -0
- package/public/assets/PrBotBehaviourTab-CCnFrWbC.js +1 -0
- package/public/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
- package/public/assets/highlight-BEHUn5zE.css +10 -0
- package/public/assets/highlight-CIIZLa6Z.js +12 -0
- package/public/assets/index-BDnYs6zg.js +73 -0
- package/public/assets/index-mCuI--RC.css +1 -0
- package/public/assets/markdown-I_JLLVAZ.js +32 -0
- package/public/assets/perfProbe-Da13EhTE.js +1 -0
- package/public/assets/tanstack-5qsxxaLe.js +17 -0
- package/public/assets/vis-C3BCkP5U.js +1280 -0
- package/public/assets/vis-ziCVehMy.css +1 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-48.png +0 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +41 -0
- package/public-landing/apple-touch-icon.png +0 -0
- package/public-landing/assets/archivo-latin-500-BWmvx2Mg.woff2 +0 -0
- package/public-landing/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
- package/public-landing/assets/index-CA3eEGXO.js +40 -0
- package/public-landing/assets/index-naQrQ6Fe.css +1 -0
- package/public-landing/assets/jetbrains-mono-latin-400-RruCX7RW.woff2 +0 -0
- package/public-landing/assets/newsreader-latin-400-DExrRRrN.woff2 +0 -0
- package/public-landing/assets/newsreader-latin-400-italic-b0I-AwbV.woff2 +0 -0
- package/public-landing/bots/index.html +156 -0
- package/public-landing/contact/index.html +156 -0
- package/public-landing/cookies/index.html +156 -0
- package/public-landing/demo/limn-walkthrough-poster.jpg +0 -0
- package/public-landing/demo/limn-walkthrough.cues.json +44 -0
- package/public-landing/demo/limn-walkthrough.mp4 +0 -0
- package/public-landing/favicon-16.png +0 -0
- package/public-landing/favicon-32.png +0 -0
- package/public-landing/favicon-48.png +0 -0
- package/public-landing/favicon.svg +5 -0
- package/public-landing/features/index.html +156 -0
- package/public-landing/for-developers/index.html +156 -0
- package/public-landing/for-managers/index.html +156 -0
- package/public-landing/how-it-works/index.html +156 -0
- package/public-landing/how-we-measure/index.html +156 -0
- package/public-landing/icon-192.png +0 -0
- package/public-landing/icon-512.png +0 -0
- package/public-landing/index.html +156 -0
- package/public-landing/insights/index.html +156 -0
- package/public-landing/og-image.png +0 -0
- package/public-landing/pricing/index.html +156 -0
- package/public-landing/privacy/index.html +156 -0
- package/public-landing/pro/index.html +156 -0
- package/public-landing/reviews/index.html +156 -0
- package/public-landing/robots.txt +8 -0
- package/public-landing/shots/benchmark.png +0 -0
- package/public-landing/shots/bot-roi.png +0 -0
- package/public-landing/shots/bot-settings.png +0 -0
- package/public-landing/shots/chronology.png +0 -0
- package/public-landing/shots/feed.png +0 -0
- package/public-landing/shots/flow-metrics.png +0 -0
- package/public-landing/shots/free-reports.png +0 -0
- package/public-landing/shots/pending-board.png +0 -0
- package/public-landing/shots/pending-card.png +0 -0
- package/public-landing/shots/period-report.png +0 -0
- package/public-landing/shots/pr-detail.png +0 -0
- package/public-landing/shots/pr-threads.png +0 -0
- package/public-landing/shots/reach.png +0 -0
- package/public-landing/shots/repo-rows.png +0 -0
- package/public-landing/shots/severity-findings.png +0 -0
- package/public-landing/shots/severity-strip.png +0 -0
- package/public-landing/site.webmanifest +14 -0
- package/public-landing/sitemap.xml +51 -0
- 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
|