tenseleyflow/shithub / 9191613

Browse files

config: add ratelimit.api budgets with defaults + validation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9191613caa7ac09ab773929de9627fb91796415a
Parents
7c44705
Tree
b9310ba

3 changed files

StatusFile+-
M .env.example 6 0
M internal/infra/config/config.go 38 0
M internal/infra/config/config_test.go 59 0
.env.examplemodified
@@ -47,3 +47,9 @@ SHITHUB_AUTH__SMTP__ADDR=127.0.0.1:1025
4747
 # AEAD key for at-rest TOTP secrets (S06). Generate once and persist —
4848
 # rotating without re-encrypting every row breaks every existing 2FA login.
4949
 # SHITHUB_TOTP_KEY=$(openssl rand -base64 32)
50
+
51
+# ----- rate limits (S50 §0) -----
52
+# Per-hour budgets for /api/v1/* requests. Authed keyed by token id;
53
+# anon keyed by remote IP. Zero falls back to the default.
54
+SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=5000
55
+SHITHUB_RATELIMIT__API__ANON_PER_HOUR=60
internal/infra/config/config.gomodified
@@ -38,6 +38,26 @@ type Config struct {
3838
 	Storage        StorageConfig        `toml:"storage"`
3939
 	Auth           AuthConfig           `toml:"auth"`
4040
 	Notif          NotifConfig          `toml:"notif"`
41
+	RateLimit      RateLimitConfig      `toml:"ratelimit"`
42
+}
43
+
44
+// RateLimitConfig configures runtime rate-limit budgets for surfaces that
45
+// don't carry a domain-specific limiter. The /api/v1/ JSON surface uses
46
+// the API.* sub-block; future surfaces (search, git transports) get their
47
+// own sub-blocks here as they're factored out of their handlers.
48
+type RateLimitConfig struct {
49
+	API APIRateLimitConfig `toml:"api"`
50
+}
51
+
52
+// APIRateLimitConfig sets the per-hour budgets for /api/v1/* requests.
53
+// AuthedPerHour applies when the caller presents a valid PAT (keyed by
54
+// token id). AnonPerHour applies to unauthenticated callers (keyed by
55
+// remote IP). Defaults are GitHub-aligned: 5000/h authed, 60/h anon —
56
+// operators tune via SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR /
57
+// SHITHUB_RATELIMIT__API__ANON_PER_HOUR.
58
+type APIRateLimitConfig struct {
59
+	AuthedPerHour int `toml:"authed_per_hour"`
60
+	AnonPerHour   int `toml:"anon_per_hour"`
4161
 }
4262
 
4363
 // NotifConfig configures the S29 notification surface. UnsubscribeKeyB64
@@ -205,6 +225,12 @@ func Defaults() Config {
205225
 				ForcePathStyle: true,
206226
 			},
207227
 		},
228
+		RateLimit: RateLimitConfig{
229
+			API: APIRateLimitConfig{
230
+				AuthedPerHour: 5000,
231
+				AnonPerHour:   60,
232
+			},
233
+		},
208234
 		Auth: AuthConfig{
209235
 			RequireEmailVerification: true,
210236
 			BaseURL:                  "http://127.0.0.1:8080",
@@ -321,6 +347,18 @@ func Validate(c *Config) error {
321347
 	if c.Auth.EmailFrom == "" {
322348
 		return errors.New("config: auth.email_from is required")
323349
 	}
350
+	if c.RateLimit.API.AuthedPerHour < 0 {
351
+		return fmt.Errorf("config: ratelimit.api.authed_per_hour: must be >= 0, got %d", c.RateLimit.API.AuthedPerHour)
352
+	}
353
+	if c.RateLimit.API.AnonPerHour < 0 {
354
+		return fmt.Errorf("config: ratelimit.api.anon_per_hour: must be >= 0, got %d", c.RateLimit.API.AnonPerHour)
355
+	}
356
+	if c.RateLimit.API.AuthedPerHour == 0 {
357
+		c.RateLimit.API.AuthedPerHour = 5000
358
+	}
359
+	if c.RateLimit.API.AnonPerHour == 0 {
360
+		c.RateLimit.API.AnonPerHour = 60
361
+	}
324362
 	return nil
325363
 }
326364
 
internal/infra/config/config_test.gomodified
@@ -108,3 +108,62 @@ func TestMergeFlags_OverridesEnv(t *testing.T) {
108108
 		t.Errorf("Web.Addr: got %q, want :7777", cfg.Web.Addr)
109109
 	}
110110
 }
111
+
112
+func TestDefaults_RateLimitAPI(t *testing.T) {
113
+	t.Parallel()
114
+	cfg := Defaults()
115
+	if cfg.RateLimit.API.AuthedPerHour != 5000 {
116
+		t.Errorf("RateLimit.API.AuthedPerHour: got %d, want 5000", cfg.RateLimit.API.AuthedPerHour)
117
+	}
118
+	if cfg.RateLimit.API.AnonPerHour != 60 {
119
+		t.Errorf("RateLimit.API.AnonPerHour: got %d, want 60", cfg.RateLimit.API.AnonPerHour)
120
+	}
121
+}
122
+
123
+func TestValidate_RejectsNegativeRateLimit(t *testing.T) {
124
+	t.Parallel()
125
+	cfg := Defaults()
126
+	cfg.RateLimit.API.AuthedPerHour = -1
127
+	if err := Validate(&cfg); err == nil {
128
+		t.Errorf("expected validation error for ratelimit.api.authed_per_hour=-1")
129
+	}
130
+	cfg = Defaults()
131
+	cfg.RateLimit.API.AnonPerHour = -5
132
+	if err := Validate(&cfg); err == nil {
133
+		t.Errorf("expected validation error for ratelimit.api.anon_per_hour=-5")
134
+	}
135
+}
136
+
137
+func TestValidate_RateLimitZeroFillsDefault(t *testing.T) {
138
+	t.Parallel()
139
+	cfg := Defaults()
140
+	cfg.RateLimit.API.AuthedPerHour = 0
141
+	cfg.RateLimit.API.AnonPerHour = 0
142
+	if err := Validate(&cfg); err != nil {
143
+		t.Fatalf("Validate: %v", err)
144
+	}
145
+	if cfg.RateLimit.API.AuthedPerHour != 5000 {
146
+		t.Errorf("zero-fill authed: got %d, want 5000", cfg.RateLimit.API.AuthedPerHour)
147
+	}
148
+	if cfg.RateLimit.API.AnonPerHour != 60 {
149
+		t.Errorf("zero-fill anon: got %d, want 60", cfg.RateLimit.API.AnonPerHour)
150
+	}
151
+}
152
+
153
+func TestMergeEnv_RateLimitAPI(t *testing.T) {
154
+	t.Parallel()
155
+	cfg := Defaults()
156
+	env := []string{
157
+		"SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR=10000",
158
+		"SHITHUB_RATELIMIT__API__ANON_PER_HOUR=120",
159
+	}
160
+	if err := mergeEnv(&cfg, env); err != nil {
161
+		t.Fatalf("mergeEnv: %v", err)
162
+	}
163
+	if cfg.RateLimit.API.AuthedPerHour != 10000 {
164
+		t.Errorf("AuthedPerHour: got %d, want 10000", cfg.RateLimit.API.AuthedPerHour)
165
+	}
166
+	if cfg.RateLimit.API.AnonPerHour != 120 {
167
+		t.Errorf("AnonPerHour: got %d, want 120", cfg.RateLimit.API.AnonPerHour)
168
+	}
169
+}