feat(enclave): per-enclave message send rate-limit override (LC-217) #259

Merged
nrupard merged 4 commits from feat/lc-217-enclave-msg-rate-limit into main 2026-05-29 18:16:21 +02:00
Owner

Summary

Per-enclave message send rate-limit override. Enclave admins set a max-messages-per-minute burst at /enclave/{id}/settings; 0 = use the site default, non-zero layers in addition to the global rate_limit_messages cap (never relaxes it). Surfaced during LC-216 review as the operator-controllable anti-spam knob the user explicitly asked for.

What ships

  • Schema: chat/0057_enclave_msg_rate_limit.sql adds enclaves.msg_rate_limit_burst INTEGER NOT NULL DEFAULT 0.
  • Model + DB: Enclave.msg_rate_limit_burst: u32, threaded through all five SELECT sites in db/enclave.rs. New set_msg_rate_limit_burst mutator and get_msg_rate_limit_burst_for_room cheap JOIN helper.
  • Enforcement: routes/room.rs::post_message adds a second rate-limit check after the existing global one. Composite key enc:{eid}:{user.id} so the per-enclave counter is independent of the global counter. DMs (no enclave) and zero-override rooms fall through. 429 with Retry-After.
  • UI: templates/enclave/settings.html gets a new section above the emoji card. One number input (0..10000) and a save button; status renders next to the form.
  • Route: POST /enclave/{id}/rate-limit with MsgRateLimitForm { burst }. Gated through the existing require_manage predicate.
  • i18n: 8 new Fluent keys in en/enclave.ftl and es/enclave.ftl.
  • Tests: 3 integration cases in tests/enclave_msg_rate_limit.rs: zero falls through, non-zero blocks at threshold, per-enclave isolation.

Test plan

  • cargo check --workspace --all-targets clean under standalone and saas.
  • cargo clippy --workspace --all-targets --features standalone -- -D warnings clean.
  • cargo test --test enclave_msg_rate_limit all three tests pass.
  • cargo fmt --all no changes.
  • Manual smoke after deploy: set burst to 5 on a test enclave, verify 6th post in <60 s returns 429 with "you are sending messages too quickly in this enclave" body; verify a sibling enclave with override 0 still respects only the global cap.

Out of scope

  • Per-room override (room-granularity is too fine; the enclave is the operator-meaningful boundary).
  • Operator-visible global default tunable (existing rate_limit_messages settings KV is the only knob today; LC-217 layers ON TOP).
## Summary Per-enclave message send rate-limit override. Enclave admins set a max-messages-per-minute burst at `/enclave/{id}/settings`; `0` = use the site default, non-zero layers in addition to the global `rate_limit_messages` cap (never relaxes it). Surfaced during LC-216 review as the operator-controllable anti-spam knob the user explicitly asked for. ## What ships - **Schema:** `chat/0057_enclave_msg_rate_limit.sql` adds `enclaves.msg_rate_limit_burst INTEGER NOT NULL DEFAULT 0`. - **Model + DB:** `Enclave.msg_rate_limit_burst: u32`, threaded through all five `SELECT` sites in `db/enclave.rs`. New `set_msg_rate_limit_burst` mutator and `get_msg_rate_limit_burst_for_room` cheap JOIN helper. - **Enforcement:** `routes/room.rs::post_message` adds a second rate-limit check after the existing global one. Composite key `enc:{eid}:{user.id}` so the per-enclave counter is independent of the global counter. DMs (no enclave) and zero-override rooms fall through. 429 with `Retry-After`. - **UI:** `templates/enclave/settings.html` gets a new section above the emoji card. One number input (0..10000) and a save button; status renders next to the form. - **Route:** `POST /enclave/{id}/rate-limit` with `MsgRateLimitForm { burst }`. Gated through the existing `require_manage` predicate. - **i18n:** 8 new Fluent keys in `en/enclave.ftl` and `es/enclave.ftl`. - **Tests:** 3 integration cases in `tests/enclave_msg_rate_limit.rs`: zero falls through, non-zero blocks at threshold, per-enclave isolation. ## Test plan - [x] `cargo check --workspace --all-targets` clean under standalone and saas. - [x] `cargo clippy --workspace --all-targets --features standalone -- -D warnings` clean. - [x] `cargo test --test enclave_msg_rate_limit` all three tests pass. - [x] `cargo fmt --all` no changes. - [ ] Manual smoke after deploy: set burst to 5 on a test enclave, verify 6th post in <60 s returns 429 with "you are sending messages too quickly in this enclave" body; verify a sibling enclave with override 0 still respects only the global cap. ## Out of scope - Per-room override (room-granularity is too fine; the enclave is the operator-meaningful boundary). - Operator-visible global default tunable (existing `rate_limit_messages` settings KV is the only knob today; LC-217 layers ON TOP).
feat(enclave): per-enclave message send rate-limit override (LC-217)
All checks were successful
check-secrets / TruffleHog (push) Successful in 3s
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 3s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 2m6s
b0fa47a823
Enclave admins can now cap message sends per-minute for any member posting in any room inside their enclave, in addition to (never relaxing) the global `rate_limit_messages` setting. Surfaced during LC-216 review as the operator-controllable anti-spam knob the user explicitly asked for.

Schema (`server/migrations/chat/0057_enclave_msg_rate_limit.sql`): one new column `enclaves.msg_rate_limit_burst INTEGER NOT NULL DEFAULT 0`. `0` means "use the global cap"; non-zero is a per-minute burst applied to in-enclave posts.

Model + DB:
- `Enclave.msg_rate_limit_burst: u32` added to `server/src/models/enclave.rs`; all five `SELECT` statements in `server/src/db/enclave.rs` and the matching struct literals carry the new column.
- `set_msg_rate_limit_burst(pool, enclave_id, burst)` mutator mirrors the `set_share_emojis_globally` shape.
- `get_msg_rate_limit_burst_for_room(pool, room_id)` is the cheap pre-gate helper. JOINs `rooms` -> `enclaves` so the caller does not need a separate "what enclave is this room in" query. Returns `(None, 0)` for DMs (no enclave) and for rooms whose enclave has the override at 0.

Enforcement (`server/src/routes/room.rs::post_message`): a new check runs AFTER the existing global `RateLimitKind::Message` gate. It calls `get_msg_rate_limit_burst_for_room`; when both an enclave id and a non-zero burst come back, it consults `state.rate_limits` with a distinct composite key `enc:{eid}:{user.id}` and the enclave's burst. A separate counter key ensures in-enclave activity is tracked independently of the global counter (which the prior call already incremented). DMs and zero-override rooms fall through. 429 with `Retry-After` on deny, mirroring the global path's error shape.

Settings UI (`server/templates/enclave/settings.html`): new section above the emoji card. One number input (0..10000) and a save button; current value renders next to the form as "Using site default" or "Limit: N per minute". Gated through the existing `require_manage` predicate that the sibling forms already use. Eight new Fluent keys in `server/locales/{en,es}/enclave.ftl`.

Route (`server/src/routes/enclave.rs`): `POST /enclave/{id}/rate-limit` with the `MsgRateLimitForm { burst }` shape. Parses to `u32`, rejects > 10_000 as `BadRequest` defense in depth (UI clamps at 10_000 already).

Tests (`server/tests/enclave_msg_rate_limit.rs`): three integration cases covering the three states a reviewer would ask about. Zero override falls through to the global cap (10 fast posts all succeed when global is 0). Non-zero override blocks at its threshold (`burst=3` -> 3 succeed, 4th 429s). Per-enclave isolation (enclave A at `burst=2` blocks at A3, enclave B at `burst=5` independently allows 5 more posts in B then blocks at B6).

Verified: `cargo check --workspace --all-targets` clean under both standalone and saas. `cargo clippy --workspace --all-targets --features standalone -- -D warnings` clean. `cargo test --test enclave_msg_rate_limit` all three tests pass. `cargo fmt --all` no changes.

Documented in CLAUDE.md is deferred to the LC-217 follow-up reviewer's note (the user's tickets stay terse; this commit body is the durable record). Out of scope for this ticket: per-room override (room-granularity is too fine), site-wide global tunable via admin settings (existing `rate_limit_messages` setting KV remains the only knob; this ticket layers ON TOP).

#LC-217
fix(enclave): code-review fixes for LC-217
All checks were successful
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 7s
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 2m5s
f50b0ed87d
Five findings addressed in one follow-up commit so PR #259 ships clean.

1. `server/src/routes/enclave.rs` MsgRateLimitForm doc claimed "UI input maxes at 600" but `templates/enclave/settings.html` sets `max="10000"`. Self-drift in the original iteration. Comment now describes the form input as 10000-capped and the server-side `> 10_000` reject as defense in depth.

2. `CLAUDE.md` gains an "Anti-spam: message send rate limits" section covering both layers (`rate_limit_messages` site setting + `enclaves.msg_rate_limit_burst` per-enclave override) and the cap-tightens-only-never-relaxes posture. Fulfills the LC-217 AC that called for operator-facing docs.

3. `server/src/routes/room.rs:478-494` pattern shape cleaned up: was `if let (Some(eid), burst) = (enclave_id_opt, enclave_burst)` followed by a nested `if burst > 0`. Now reads as a straight `if let Some(eid) = enclave_id_opt { if enclave_burst > 0 {`. Equivalent semantics, less noise.

4. `server/tests/enclave_msg_rate_limit.rs` status assertions tightened from `s == StatusCode::SEE_OTHER || s == StatusCode::OK` to `assert_eq!(s, StatusCode::OK)`. `post_message` returns `Result<Html, AppError>` so the success status is exactly 200; the OR was a pre-pin defensive shape. Tighter regression coverage.

5. `cargo test --no-default-features --features saas --test enclave_msg_rate_limit` verified runtime-pass under saas mode (the original PR only verified compilation). All three tests green.

`cargo fmt --all` no-op. `cargo clippy --workspace --all-targets --features standalone -- -D warnings` clean.

#LC-217
fix(test): tighten missed assertion in limits_are_per_enclave (LC-217)
Some checks failed
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
check-secrets / Kingfisher (push) Successful in 7s
check-secrets / TruffleHog (pull_request) Successful in 8s
check-secrets / Nosey parker (pull_request) Successful in 8s
check-secrets / Kingfisher (pull_request) Successful in 10s
Check / clippy + fmt + tests (pull_request) Has been cancelled
3a3ecc895e
Second-pass review of the LC-217 follow-up commit caught one missed `replace_all` site. The inner B-enclave loop in `limits_are_per_enclave` still used the loose `assert!(s == SEE_OTHER || s == OK)` shape because the message text ("B post {i}") differed from the others ("post {i}") so the replace did not match. Tightened to `assert_eq!(s, StatusCode::OK)` like the other five sites in the file.

Test still green: `cargo test --test enclave_msg_rate_limit` 3/3 passes.

#LC-217
docs(claude-md): polish LC-217 anti-spam section
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / TruffleHog (push) Successful in 3s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / Kingfisher (push) Successful in 5s
Check / clippy + fmt + tests (pull_request) Successful in 2m9s
Create release / Create release from merged PR (pull_request) Has been skipped
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / TruffleHog (pull_request) Successful in 6s
333465121b
Two LOW-severity findings from the second-pass review:

1. "IN ADDITION" shouty all-caps -> lowercase "in addition" (the explanatory paragraph below already calls out the never-relax posture; the all-caps emphasis is redundant noise).
2. POST endpoint added alongside the GET. Was: "Enclave admins set it at `/enclave/{id}/settings`". Now also names `POST /enclave/{id}/rate-limit` with the `require_manage` gate and `burst` form field. Grep-friendly for the next operator hunting the surface.

#LC-217
nrupard deleted branch feat/lc-217-enclave-msg-rate-limit 2026-05-29 18:16:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!259
No description provided.