diff --git a/README.md b/README.md index be3f0ea..9418f11 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.quizzes.*`. | `golem15.quizzes.scoring.multiple_partial` | `false` | Partial credit on `multiple` questions: `(correct chosen - wrong chosen) / correct answers` of the points, floored at 0. Without it only the exact set earns points. | | `golem15.quizzes.scoring.penalty_wrong` | `0` | Fraction (0 to 1) of a question's points subtracted for a wrong answer: a non-empty `single` or `multiple` answer that earns nothing. `self_check` answers marked as unknown and empty answers are never penalised. An attempt's total never goes below 0. | | `golem15.quizzes.test.grace_seconds` | `5` | Seconds after a test's time limit during which an answer still in flight is accepted, clamped to 0..120. A missing or non-numeric value is 5. Learning quizzes have no grace. | +| `golem15.quizzes.throttle.answers_per_minute` | `60` | Per-user limit of `POST /attempts/{id}/answers` (bucket `golem15.quizzes.answer`). With a grader such as `ai`, every new or changed open answer in a learning quiz is a paid grading call. A missing, non-numeric or sub-1 value is 60. | +| `golem15.quizzes.throttle.attempts_per_minute` | `10` | Per-user limit of `POST /quizzes/{slug}/attempts` (bucket `golem15.quizzes.start`). A missing, non-numeric or sub-1 value is 10. | Scores are rounded to two decimals. diff --git a/classes/policy.go b/classes/policy.go index 1d5641e..146346f 100644 --- a/classes/policy.go +++ b/classes/policy.go @@ -52,6 +52,36 @@ func TestGraceFrom(cfg *compass.Config) time.Duration { return min(max(grace, 0), maxTestGrace) } +// Default per-user rate limits of the student write routes, per minute. In +// a learning quiz with a grader every new or changed open answer is a paid +// grading call, so answering and starting attempts are throttled (WR-03). +const ( + DefaultAnswersPerMinute = 60 + DefaultAttemptsPerMinute = 10 +) + +// maxPerMinute caps a configured per-minute limit. +const maxPerMinute = 10000 + +// PerMinuteFrom reads a per-minute rate limit at key (for example +// golem15.quizzes.throttle.answers_per_minute). A nil config, a missing +// key or a value that is not a number of at least 1 give def; the value +// is truncated to a whole number and capped at 10000. +func PerMinuteFrom(cfg *compass.Config, key string, def int) int { + if cfg == nil { + return def + } + v, ok := cfg.Lookup(key) + if !ok { + return def + } + n, ok := number(v) + if !ok || n < 1 || math.IsInf(n, 0) { + return def + } + return int(min(n, maxPerMinute)) +} + // number is v as a float when it is a number or a numeric string. func number(v any) (float64, bool) { switch n := v.(type) { diff --git a/config/config.yaml b/config/config.yaml index 0c6546c..18aacfe 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -12,3 +12,9 @@ test: # Seconds after a test's time limit during which an answer still in flight # is accepted (0 to 120). Learning quizzes have no grace. grace_seconds: 5 +throttle: + # Per-user limits of the student's answers and attempt starts, per minute. + # With a grader such as ai, every new or changed open answer in a learning + # quiz is a paid grading call, so keep these low. + answers_per_minute: 60 + attempts_per_minute: 10 diff --git a/plugin.go b/plugin.go index 95552a4..ff70901 100644 --- a/plugin.go +++ b/plugin.go @@ -226,13 +226,19 @@ func (p *Plugin) FrontendPermissions() []pact.FrontendPermission { }} } -// Buckets declares the per-user rate limit of the owner manage routes: -// building a quiz question by question is chatty, so 120 calls a minute. -// A request without a user (never after user.api_token) keys by client IP. +// Buckets declares the per-user rate limits: the owner manage routes +// (building a quiz question by question is chatty, so 120 calls a minute), +// and the student's answers and attempt starts, configurable as +// golem15.quizzes.throttle.answers_per_minute and attempts_per_minute. A +// request without a user (never after user.api_token or jwt.auth) keys by +// client IP. func (p *Plugin) Buckets() map[string]surf.Bucket { trusted := surf.TrustedProxies(nil) + answers, starts := classes.DefaultAnswersPerMinute, classes.DefaultAttemptsPerMinute if p != nil && p.app != nil { trusted = surf.TrustedProxies(p.app.Config) + answers = classes.PerMinuteFrom(p.app.Config, "golem15.quizzes.throttle.answers_per_minute", answers) + starts = classes.PerMinuteFrom(p.app.Config, "golem15.quizzes.throttle.attempts_per_minute", starts) } byUser := func(prefix string) func(*http.Request) string { return func(r *http.Request) string { @@ -244,6 +250,8 @@ func (p *Plugin) Buckets() map[string]surf.Bucket { } return map[string]surf.Bucket{ bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)}, + bucketAnswer: {Max: answers, Decay: time.Minute, Key: byUser(bucketAnswer)}, + bucketStart: {Max: starts, Decay: time.Minute, Key: byUser(bucketStart)}, } } diff --git a/routes.go b/routes.go index f16ac56..9a01b2f 100644 --- a/routes.go +++ b/routes.go @@ -24,11 +24,21 @@ const permQuizzes = "golem15.quizzes.access_quizzes" // after user.api_token, so it keys by user. const bucketManage = "golem15.quizzes.manage" +// bucketAnswer and bucketStart limit the student's answers and attempt +// starts. They run after jwt.auth, so they key by user. In a learning quiz +// with a grader every new or changed open answer is a paid grading call, +// and every new attempt starts a fresh answer row (WR-03). +const ( + bucketAnswer = "golem15.quizzes.answer" + bucketStart = "golem15.quizzes.start" +) + // Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's // jwt.auth middleware, and the management API under /api/v1/quizzes behind // its application-wide API token: the import route, and the owner manage // routes the MCP quiz tools bind, which also need the access_quizzes // permission and run under the per-user golem15.quizzes.manage throttle. +// Answering and starting an attempt run under per-user throttles too. // The owner review routes under /api/v1/quiz-reviews (the MCP review // tools) run behind the same middleware and permission (D-13). func (p *Plugin) Routes(r pact.Router) error { @@ -37,12 +47,12 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/quizzes", h.ListQuizzes) g.Get("/quizzes/{slug}", h.ShowQuiz) g.Where("slug", `[a-z0-9-]+`) - g.Post("/quizzes/{slug}/attempts", h.StartAttempt) + g.Post("/quizzes/{slug}/attempts", h.StartAttempt, "throttle:"+bucketStart) g.Where("slug", `[a-z0-9-]+`) g.Get("/attempts", h.ListAttempts) g.Get("/attempts/{id}", h.ShowAttempt) g.Where("id", `[0-9]+`) - g.Post("/attempts/{id}/answers", h.Answer) + g.Post("/attempts/{id}/answers", h.Answer, "throttle:"+bucketAnswer) g.Where("id", `[0-9]+`) g.Post("/attempts/{id}/finish", h.Finish) g.Where("id", `[0-9]+`)