claude-dev-env 1.83.0 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/_shared/pr-loop/audit-contract.md +24 -12
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +16 -5
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +8 -0
  6. package/_shared/pr-loop/scripts/reviews_disabled.py +2 -0
  7. package/_shared/pr-loop/scripts/terminology_sweep.py +85 -39
  8. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +212 -0
  9. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +18 -0
  10. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +18 -0
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +37 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +14 -0
  13. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +94 -4
  14. package/agents/clean-coder.md +3 -0
  15. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  16. package/bin/install.mjs +16 -8
  17. package/bin/install.test.mjs +17 -6
  18. package/commands/CLAUDE.md +0 -1
  19. package/docs/CODE_RULES.md +1 -1
  20. package/hooks/blocking/CLAUDE.md +0 -4
  21. package/hooks/blocking/claude_md_orphan_file_blocker.py +1 -1
  22. package/hooks/blocking/code_rules_constants_config.py +5 -0
  23. package/hooks/blocking/code_rules_docstrings.py +133 -19
  24. package/hooks/blocking/code_rules_enforcer.py +4 -0
  25. package/hooks/blocking/code_rules_imports_logging.py +10 -6
  26. package/hooks/blocking/code_rules_magic_values.py +5 -0
  27. package/hooks/blocking/code_rules_naming_collection.py +15 -8
  28. package/hooks/blocking/code_rules_paired_test.py +3 -2
  29. package/hooks/blocking/code_rules_shared.py +34 -0
  30. package/hooks/blocking/code_rules_string_magic.py +1 -1
  31. package/hooks/blocking/code_rules_unused_imports.py +2 -2
  32. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +4 -4
  33. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +29 -0
  34. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +197 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +27 -0
  36. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +24 -0
  37. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +33 -0
  38. package/hooks/blocking/test_code_rules_enforcer_split_shared.py +23 -0
  39. package/hooks/blocking/test_code_rules_magic_values.py +54 -0
  40. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +0 -6
  41. package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +184 -0
  42. package/hooks/blocking/test_shared_stdin_adoption.py +5 -30
  43. package/hooks/hooks_constants/CLAUDE.md +0 -3
  44. package/hooks/hooks_constants/blocking_check_limits.py +4 -0
  45. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  46. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +0 -1
  47. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -5
  48. package/hooks/validation/post_tool_use_dispatcher.py +1 -1
  49. package/hooks/validation/test_post_tool_use_dispatcher.py +51 -35
  50. package/hooks/workflow/CLAUDE.md +2 -8
  51. package/package.json +1 -1
  52. package/rules/CLAUDE.md +1 -1
  53. package/rules/docstring-prose-matches-implementation.md +1 -1
  54. package/rules/env-var-table-code-drift.md +1 -1
  55. package/rules/package-inventory-stale-entry.md +1 -1
  56. package/rules/paired-test-coverage.md +1 -1
  57. package/rules/plain-illustrative-docstrings.md +35 -1
  58. package/skills/CLAUDE.md +6 -1
  59. package/skills/_shared/pr-loop/CLAUDE.md +1 -0
  60. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  61. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  62. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/handoff_constants.py +45 -0
  63. package/skills/_shared/pr-loop/scripts/test_write_handoff.py +201 -0
  64. package/skills/_shared/pr-loop/scripts/write_handoff.py +309 -0
  65. package/skills/autoconverge/SKILL.md +82 -58
  66. package/skills/autoconverge/reference/closing-report.md +6 -6
  67. package/skills/autoconverge/reference/convergence.md +17 -15
  68. package/skills/autoconverge/reference/gotchas.md +6 -3
  69. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +2 -9
  70. package/skills/autoconverge/workflow/converge.contract.test.mjs +87 -13
  71. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +1 -1
  72. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -9
  73. package/skills/autoconverge/workflow/converge.mjs +71 -74
  74. package/skills/autoconverge/workflow/render_report.py +7 -11
  75. package/skills/bugteam/CLAUDE.md +1 -1
  76. package/skills/bugteam/PROMPTS.md +7 -6
  77. package/skills/bugteam/SKILL.md +25 -72
  78. package/skills/bugteam/reference/CLAUDE.md +1 -3
  79. package/skills/bugteam/reference/README.md +1 -1
  80. package/skills/bugteam/reference/audit-and-teammates.md +1 -1
  81. package/skills/bugteam/reference/obstacles/fix-publish-summary.md +1 -1
  82. package/skills/bugteam/reference/team-setup.md +8 -7
  83. package/skills/bugteam/scripts/CLAUDE.md +0 -6
  84. package/skills/bugteam/scripts/README.md +0 -4
  85. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +0 -1
  86. package/skills/code/SKILL.md +2 -0
  87. package/skills/copilot-review/CLAUDE.md +1 -1
  88. package/skills/copilot-review/SKILL.md +25 -23
  89. package/skills/findbugs/CLAUDE.md +2 -2
  90. package/skills/findbugs/SKILL.md +22 -83
  91. package/skills/fixbugs/SKILL.md +2 -4
  92. package/skills/log-audit/CLAUDE.md +20 -0
  93. package/skills/log-audit/SKILL.md +68 -0
  94. package/skills/log-audit/reference/CLAUDE.md +9 -0
  95. package/skills/log-audit/reference/charter.md +52 -0
  96. package/skills/log-audit/scripts/CLAUDE.md +27 -0
  97. package/skills/log-audit/scripts/cluster_recurrences.py +261 -0
  98. package/skills/log-audit/scripts/collect_log_window.py +199 -0
  99. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +12 -0
  100. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +23 -0
  101. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +24 -0
  102. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +49 -0
  103. package/skills/log-audit/scripts/mine_copilot_findings.py +302 -0
  104. package/skills/log-audit/scripts/test_cluster_recurrences.py +160 -0
  105. package/skills/log-audit/scripts/test_collect_log_window.py +111 -0
  106. package/skills/log-audit/scripts/test_mine_copilot_findings.py +126 -0
  107. package/skills/monitor-open-prs/SKILL.md +2 -2
  108. package/skills/post-audit-findings/SKILL.md +84 -0
  109. package/skills/pr-converge/CLAUDE.md +2 -0
  110. package/skills/pr-converge/SKILL.md +99 -60
  111. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  112. package/skills/pr-converge/reference/convergence-gates.md +16 -19
  113. package/skills/pr-converge/reference/examples.md +5 -5
  114. package/skills/pr-converge/reference/fix-protocol.md +16 -43
  115. package/skills/pr-converge/reference/obstacles/fix-publish-summary.md +1 -1
  116. package/skills/pr-converge/reference/per-tick.md +24 -45
  117. package/skills/pr-converge/reference/state-schema.md +27 -0
  118. package/skills/pr-converge/scripts/README.md +3 -5
  119. package/skills/pr-fix-protocol/SKILL.md +70 -0
  120. package/skills/pr-loop-lifecycle/SKILL.md +73 -0
  121. package/skills/{bugteam → pr-loop-lifecycle}/reference/teardown-publish-permissions.md +22 -24
  122. package/skills/pr-scope-resolve/SKILL.md +48 -0
  123. package/skills/qbug/CLAUDE.md +4 -4
  124. package/skills/qbug/SKILL.md +46 -144
  125. package/skills/qbug/test_qbug_skill_audit_schema.py +2 -2
  126. package/skills/qbug/test_qbug_skill_post_fix_audit.py +1 -1
  127. package/skills/reviewer-gates/SKILL.md +96 -0
  128. package/skills/session-log/CLAUDE.md +7 -7
  129. package/skills/session-log/SKILL.md +27 -44
  130. package/skills/test_markdown_link_integrity.py +103 -0
  131. package/commands/doc-gist.md +0 -16
  132. package/hooks/blocking/_md_to_html_blocker_test_support.py +0 -65
  133. package/hooks/blocking/conftest.py +0 -30
  134. package/hooks/blocking/md_path_exemptions.py +0 -224
  135. package/hooks/blocking/md_to_html_blocker.py +0 -155
  136. package/hooks/blocking/test_md_to_html_blocker_exemptions.py +0 -434
  137. package/hooks/blocking/test_md_to_html_blocker_extensions.py +0 -157
  138. package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +0 -336
  139. package/hooks/hooks_constants/doc_gist_auto_publish_constants.py +0 -18
  140. package/hooks/hooks_constants/html_companion_constants.py +0 -20
  141. package/hooks/hooks_constants/md_to_html_blocker_constants.py +0 -76
  142. package/hooks/hooks_constants/test_md_to_html_blocker_constants.py +0 -125
  143. package/hooks/workflow/doc_gist_auto_publish.py +0 -144
  144. package/hooks/workflow/md_to_html_companion.py +0 -358
  145. package/hooks/workflow/test_doc_gist_auto_publish.py +0 -117
  146. package/hooks/workflow/test_md_to_html_companion.py +0 -613
  147. package/skills/bugteam/reference/audit-contract.md +0 -163
  148. package/skills/bugteam/scripts/_bugteam_permissions_common.py +0 -455
  149. package/skills/bugteam/scripts/bugteam_scripts_constants/claude_permissions_common_constants.py +0 -69
  150. package/skills/bugteam/scripts/grant_project_claude_permissions.py +0 -280
  151. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +0 -266
  152. package/skills/bugteam/scripts/test__bugteam_permissions_common.py +0 -160
  153. package/skills/bugteam/scripts/test_agent_config_carveout.py +0 -356
  154. package/skills/bugteam/scripts/test_bugteam_permissions_common.py +0 -140
  155. package/skills/doc-gist/CLAUDE.md +0 -25
  156. package/skills/doc-gist/SKILL.md +0 -97
  157. package/skills/doc-gist/references/CLAUDE.md +0 -9
  158. package/skills/doc-gist/references/examples/01-exploration-code-approaches.html +0 -453
  159. package/skills/doc-gist/references/examples/02-exploration-visual-designs.html +0 -515
  160. package/skills/doc-gist/references/examples/03-code-review-pr.html +0 -638
  161. package/skills/doc-gist/references/examples/04-code-understanding.html +0 -491
  162. package/skills/doc-gist/references/examples/05-design-system.html +0 -629
  163. package/skills/doc-gist/references/examples/06-component-variants.html +0 -605
  164. package/skills/doc-gist/references/examples/07-prototype-animation.html +0 -455
  165. package/skills/doc-gist/references/examples/08-prototype-interaction.html +0 -396
  166. package/skills/doc-gist/references/examples/09-slide-deck.html +0 -592
  167. package/skills/doc-gist/references/examples/10-svg-illustrations.html +0 -492
  168. package/skills/doc-gist/references/examples/11-status-report.html +0 -528
  169. package/skills/doc-gist/references/examples/12-incident-report.html +0 -596
  170. package/skills/doc-gist/references/examples/13-flowchart-diagram.html +0 -395
  171. package/skills/doc-gist/references/examples/14-research-feature-explainer.html +0 -381
  172. package/skills/doc-gist/references/examples/15-research-concept-explainer.html +0 -368
  173. package/skills/doc-gist/references/examples/16-implementation-plan.html +0 -702
  174. package/skills/doc-gist/references/examples/17-pr-writeup.html +0 -595
  175. package/skills/doc-gist/references/examples/18-editor-triage-board.html +0 -573
  176. package/skills/doc-gist/references/examples/19-editor-feature-flags.html +0 -663
  177. package/skills/doc-gist/references/examples/20-editor-prompt-tuner.html +0 -722
  178. package/skills/doc-gist/references/examples/21-decision-signoff.html +0 -546
  179. package/skills/doc-gist/references/examples/CLAUDE.md +0 -25
  180. package/skills/doc-gist/references/examples/README.md +0 -5
  181. package/skills/doc-gist/scripts/CLAUDE.md +0 -27
  182. package/skills/doc-gist/scripts/doc_gist_scripts_constants/CLAUDE.md +0 -10
  183. package/skills/doc-gist/scripts/doc_gist_scripts_constants/gist_upload_constants.py +0 -16
  184. package/skills/doc-gist/scripts/gist_upload.py +0 -177
  185. package/skills/doc-gist/scripts/test_gist_upload.py +0 -51
  186. /package/skills/{doc-gist/scripts/doc_gist_scripts_constants → log-audit/scripts/log_audit_constants}/__init__.py +0 -0
@@ -1,381 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>How rate limiting works in birchline/api</title>
7
- <style>
8
- :root {
9
- --ivory: #FAF9F5;
10
- --slate: #141413;
11
- --clay: #D97757;
12
- --oat: #E3DACC;
13
- --olive: #788C5D;
14
- --gray-150:#F0EEE6;
15
- --gray-300:#D1CFC5;
16
- --gray-500:#87867F;
17
- --gray-700:#3D3D3A;
18
- --serif: ui-serif, Georgia, "Times New Roman", serif;
19
- --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
20
- --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
21
- }
22
- * { box-sizing: border-box; margin: 0; padding: 0; }
23
- body {
24
- background: var(--ivory);
25
- color: var(--gray-700);
26
- font-family: var(--sans);
27
- font-size: 15px;
28
- line-height: 1.65;
29
- -webkit-font-smoothing: antialiased;
30
- padding: 56px 24px 120px;
31
- }
32
- .page {
33
- max-width: 1100px;
34
- margin: 0 auto;
35
- display: grid;
36
- grid-template-columns: 200px minmax(0, 1fr);
37
- gap: 48px;
38
- }
39
- @media (max-width: 920px) { .page { grid-template-columns: 1fr; } nav { display: none; } }
40
-
41
- /* ── nav ──────────────────────────────── */
42
- nav {
43
- position: sticky;
44
- top: 32px;
45
- align-self: start;
46
- font-size: 13px;
47
- }
48
- nav .label {
49
- font-family: var(--mono);
50
- font-size: 10px;
51
- letter-spacing: 0.1em;
52
- text-transform: uppercase;
53
- color: var(--gray-500);
54
- margin-bottom: 12px;
55
- }
56
- nav a {
57
- display: block;
58
- padding: 5px 0 5px 12px;
59
- border-left: 2px solid var(--gray-300);
60
- color: var(--gray-700);
61
- text-decoration: none;
62
- }
63
- nav a:hover { color: var(--slate); border-color: var(--slate); }
64
- nav a.l2 { padding-left: 24px; font-size: 12.5px; color: var(--gray-500); }
65
- nav .files {
66
- margin-top: 28px;
67
- border-top: 1px solid var(--gray-300);
68
- padding-top: 16px;
69
- }
70
- nav .files code {
71
- display: block;
72
- font-family: var(--mono);
73
- font-size: 11px;
74
- color: var(--gray-500);
75
- padding: 3px 0;
76
- }
77
-
78
- /* ── header ───────────────────────────── */
79
- header { margin-bottom: 12px; }
80
- .eyebrow {
81
- font-family: var(--mono);
82
- font-size: 11px;
83
- letter-spacing: 0.08em;
84
- text-transform: uppercase;
85
- color: var(--gray-500);
86
- margin-bottom: 10px;
87
- }
88
- h1 {
89
- font-family: var(--serif);
90
- font-weight: 500;
91
- font-size: 32px;
92
- color: var(--slate);
93
- letter-spacing: -0.01em;
94
- margin-bottom: 14px;
95
- }
96
- .tldr {
97
- border: 1.5px solid var(--gray-300);
98
- border-left: 3px solid var(--clay);
99
- border-radius: 10px;
100
- background: #fff;
101
- padding: 16px 18px;
102
- margin-bottom: 8px;
103
- }
104
- .tldr b { color: var(--slate); }
105
-
106
- h2 {
107
- font-family: var(--serif);
108
- font-weight: 500;
109
- font-size: 22px;
110
- color: var(--slate);
111
- margin: 40px 0 14px;
112
- scroll-margin-top: 24px;
113
- }
114
- p { margin-bottom: 12px; max-width: 680px; }
115
- code { font-family: var(--mono); font-size: 13px; }
116
-
117
- /* ── collapsible ─────────────────────── */
118
- details {
119
- border: 1.5px solid var(--gray-300);
120
- border-radius: 10px;
121
- background: #fff;
122
- margin: 14px 0;
123
- overflow: hidden;
124
- }
125
- summary {
126
- list-style: none;
127
- cursor: pointer;
128
- padding: 14px 16px;
129
- font-family: var(--serif);
130
- font-size: 16px;
131
- color: var(--slate);
132
- display: flex;
133
- align-items: baseline;
134
- gap: 10px;
135
- }
136
- summary::-webkit-details-marker { display: none; }
137
- summary::before {
138
- content: "▸";
139
- color: var(--clay);
140
- font-family: var(--sans);
141
- font-size: 12px;
142
- transition: transform 120ms;
143
- }
144
- details[open] summary::before { transform: rotate(90deg); }
145
- summary .where {
146
- font-family: var(--mono);
147
- font-size: 11px;
148
- color: var(--gray-500);
149
- margin-left: auto;
150
- }
151
- details .body { padding: 0 16px 16px; }
152
- details .body p { font-size: 14px; }
153
-
154
- /* ── tabs ─────────────────────────────── */
155
- .tabs {
156
- border: 1.5px solid var(--gray-300);
157
- border-radius: 10px;
158
- background: #fff;
159
- margin: 16px 0 8px;
160
- overflow: hidden;
161
- }
162
- .tabbar {
163
- display: flex;
164
- border-bottom: 1px solid var(--gray-300);
165
- background: var(--gray-150);
166
- }
167
- .tabbar button {
168
- appearance: none;
169
- border: none;
170
- background: none;
171
- font-family: var(--mono);
172
- font-size: 12px;
173
- color: var(--gray-500);
174
- padding: 10px 16px;
175
- cursor: pointer;
176
- border-right: 1px solid var(--gray-300);
177
- }
178
- .tabbar button.on {
179
- background: #fff;
180
- color: var(--slate);
181
- border-bottom: 2px solid var(--clay);
182
- margin-bottom: -1px;
183
- }
184
- .tabs pre {
185
- display: none;
186
- margin: 0;
187
- padding: 16px 18px;
188
- font-family: var(--mono);
189
- font-size: 12.5px;
190
- line-height: 1.6;
191
- color: var(--slate);
192
- overflow-x: auto;
193
- }
194
- .tabs pre.on { display: block; }
195
- .hl { color: var(--clay); }
196
- .cm { color: var(--gray-500); }
197
-
198
- /* ── callout ──────────────────────────── */
199
- .callout {
200
- display: flex;
201
- gap: 12px;
202
- border: 1.5px solid var(--oat);
203
- background: rgba(227,218,204,0.35);
204
- border-radius: 10px;
205
- padding: 14px 16px;
206
- margin: 18px 0;
207
- font-size: 14px;
208
- }
209
- .callout .ico { color: var(--clay); font-weight: 600; }
210
-
211
- /* ── faq ──────────────────────────────── */
212
- dl.faq { margin-top: 8px; }
213
- dl.faq dt {
214
- font-family: var(--serif);
215
- font-size: 16px;
216
- color: var(--slate);
217
- margin-top: 18px;
218
- }
219
- dl.faq dd { font-size: 14px; margin: 4px 0 0; max-width: 640px; }
220
- </style>
221
- </head>
222
- <body>
223
- <div class="page">
224
-
225
- <!-- ── side nav ── -->
226
- <nav>
227
- <div class="label">On this page</div>
228
- <a href="#tldr">TL;DR</a>
229
- <a href="#path">Request path</a>
230
- <a href="#path" class="l2">1. Identify</a>
231
- <a href="#path" class="l2">2. Bucket lookup</a>
232
- <a href="#path" class="l2">3. Consume</a>
233
- <a href="#path" class="l2">4. Reject</a>
234
- <a href="#config">Configuring a route</a>
235
- <a href="#gotchas">Gotchas</a>
236
- <a href="#faq">FAQ</a>
237
- <div class="files">
238
- <div class="label">Files read</div>
239
- <code>middleware/ratelimit.ts</code>
240
- <code>lib/tokenBucket.ts</code>
241
- <code>config/limits.yaml</code>
242
- <code>routes/*.ts</code>
243
- </div>
244
- </nav>
245
-
246
- <!-- ── main ── -->
247
- <main>
248
- <header>
249
- <div class="eyebrow">Research &amp; Learning · feature summary</div>
250
- <h1>How rate limiting works in <code>birchline/api</code></h1>
251
- <div class="tldr" id="tldr">
252
- <b>TL;DR</b> — Every request passes through <code>rateLimit()</code> middleware, which resolves the
253
- caller to a <em>bucket key</em>, fetches a token-bucket from Redis, and either consumes one token or
254
- returns <code>429</code>. Limits are declared per-route in <code>config/limits.yaml</code>; routes
255
- without an entry inherit the <code>default</code> tier (100 req/min per API key).
256
- </div>
257
- </header>
258
-
259
- <h2 id="path">The request path, step by step</h2>
260
- <p>Expand each step to see what runs and where it lives. The whole path is ~40 lines and adds about
261
- 0.4&nbsp;ms p50 to every request.</p>
262
-
263
- <details open>
264
- <summary>1 · Identify the caller <span class="where">middleware/ratelimit.ts:21</span></summary>
265
- <div class="body">
266
- <p>The middleware first reduces the request to a <code>bucketKey</code>: API key if an
267
- <code>Authorization</code> header is present, otherwise the client IP (via the
268
- <code>x-forwarded-for</code> chain, trusting only our own LB). Anonymous IP traffic gets a much
269
- lower default tier.</p>
270
- </div>
271
- </details>
272
-
273
- <details>
274
- <summary>2 · Look up the bucket <span class="where">lib/tokenBucket.ts:9</span></summary>
275
- <div class="body">
276
- <p>The route name plus bucket key map to a Redis hash (<code>rl:{route}:{key}</code>) holding
277
- <code>tokens</code> and <code>updatedAt</code>. If the key is missing it's created lazily at full
278
- capacity — there's no warm-up.</p>
279
- </div>
280
- </details>
281
-
282
- <details>
283
- <summary>3 · Refill and consume <span class="where">lib/tokenBucket.ts:31</span></summary>
284
- <div class="body">
285
- <p>Refill is computed from elapsed time (<code>rate × Δt</code>, capped at <code>burst</code>), then
286
- one token is subtracted. The whole read-modify-write runs as a single Lua script so concurrent
287
- requests can't double-spend.</p>
288
- </div>
289
- </details>
290
-
291
- <details>
292
- <summary>4 · Reject when empty <span class="where">middleware/ratelimit.ts:48</span></summary>
293
- <div class="body">
294
- <p>If the script returns <code>tokens &lt; 0</code> the middleware short-circuits with
295
- <code>429 Too Many Requests</code> and sets <code>Retry-After</code> to the seconds until one token
296
- refills. Successful responses always carry <code>X-RateLimit-Remaining</code>.</p>
297
- </div>
298
- </details>
299
-
300
- <h2 id="config">Configuring a limit on your route</h2>
301
- <p>You don't touch the middleware. Add an entry to <code>config/limits.yaml</code> keyed by route name,
302
- and (optionally) tag the route so the middleware can find it.</p>
303
-
304
- <div class="tabs" data-tabs>
305
- <div class="tabbar">
306
- <button class="on" data-t="0">limits.yaml</button>
307
- <button data-t="1">route.ts</button>
308
- <button data-t="2">client response</button>
309
- </div>
310
- <pre class="on"><span class="cm"># config/limits.yaml</span>
311
- default:
312
- rate: 100/min
313
- burst: 120
314
-
315
- <span class="hl">search.query</span>:
316
- rate: 20/min
317
- burst: 40
318
- key: api_key <span class="cm"># or: ip</span></pre>
319
- <pre><span class="cm">// routes/search.ts</span>
320
- router.post(
321
- "/search",
322
- <span class="hl">rateLimit("search.query")</span>,
323
- handler,
324
- );</pre>
325
- <pre>HTTP/1.1 429 Too Many Requests
326
- Retry-After: 17
327
- X-RateLimit-Limit: 20
328
- X-RateLimit-Remaining: 0
329
-
330
- { "error": "rate_limited", "retry_after": 17 }</pre>
331
- </div>
332
-
333
- <div class="callout">
334
- <span class="ico">★</span>
335
- <div>If you only need the default tier, you don't need a YAML entry at all — just wrap the handler in
336
- <code>rateLimit()</code> with no argument. The route name is inferred from the path.</div>
337
- </div>
338
-
339
- <h2 id="gotchas">Gotchas worth knowing</h2>
340
- <ul style="padding-left:20px; max-width:680px;">
341
- <li style="margin-bottom:8px;"><b>Limits are per-process in dev.</b> The Redis client falls back to an
342
- in-memory map when <code>REDIS_URL</code> is unset, so local testing won't reflect real cluster
343
- behaviour.</li>
344
- <li style="margin-bottom:8px;"><b>Burst ≠ rate.</b> <code>burst</code> is the bucket capacity; a caller
345
- idle for a minute can fire <code>burst</code> requests instantly even if <code>rate</code> is low.</li>
346
- <li><b>Streaming responses count once.</b> The token is consumed at request start; a 30-second SSE
347
- stream still costs one token.</li>
348
- </ul>
349
-
350
- <h2 id="faq">FAQ</h2>
351
- <dl class="faq">
352
- <dt>How do I exempt internal traffic?</dt>
353
- <dd>Set <code>x-birchline-internal: 1</code> from the caller; the middleware checks it against the
354
- mTLS peer name and skips the bucket entirely.</dd>
355
-
356
- <dt>Where do I see who's getting limited?</dt>
357
- <dd>Every <code>429</code> emits a <code>ratelimit.rejected</code> metric tagged with route and key
358
- type. There's a Grafana panel under <em>API → Health</em>.</dd>
359
-
360
- <dt>Can a single user have a higher limit?</dt>
361
- <dd>Yes — add their API key under <code>overrides:</code> in the YAML. Overrides are reloaded without
362
- a deploy.</dd>
363
- </dl>
364
- </main>
365
-
366
- </div>
367
-
368
- <script>
369
- document.querySelectorAll("[data-tabs]").forEach(box => {
370
- const btns = box.querySelectorAll("button");
371
- const panes = box.querySelectorAll("pre");
372
- btns.forEach(b => b.addEventListener("click", () => {
373
- btns.forEach(x => x.classList.remove("on"));
374
- panes.forEach(x => x.classList.remove("on"));
375
- b.classList.add("on");
376
- panes[+b.dataset.t].classList.add("on");
377
- }));
378
- });
379
- </script>
380
- </body>
381
- </html>