rateLimit.invitationsCreate (prod 20/15min, wired on POST /api/invitations) bounds burst rate, but a sliding window never bounds cumulative volume: a patient client staying under the window cap can create invitations indefinitely. When billing.referral rewards are enabled, total invitations per account — not rate — is the exposure that matters (credit farming via fake referrer/referee pairs).
Mechanism (config-gated, stack-neutral):
- DB-backed check in
invitations.service.js::create(): count invitations with the same invitedBy (all statuses), reject with the existing 422 AppError pattern (same shape as the self-invite guard) once the count reaches config.invitations.maxLifetime.
- Default: absent/
null = disabled (mirrors billing.referral, OFF at stack level). Downstream consumers opt in with a number in their config layer.
- Tests: capped path (422 at threshold), boundary (cap−1 passes), uncapped default (no behavior change).
Out of scope: the burst limiter itself (already wired, per-route profile).
rateLimit.invitationsCreate(prod 20/15min, wired onPOST /api/invitations) bounds burst rate, but a sliding window never bounds cumulative volume: a patient client staying under the window cap can create invitations indefinitely. Whenbilling.referralrewards are enabled, total invitations per account — not rate — is the exposure that matters (credit farming via fake referrer/referee pairs).Mechanism (config-gated, stack-neutral):
invitations.service.js::create(): count invitations with the sameinvitedBy(all statuses), reject with the existing 422AppErrorpattern (same shape as the self-invite guard) once the count reachesconfig.invitations.maxLifetime.null= disabled (mirrorsbilling.referral, OFF at stack level). Downstream consumers opt in with a number in their config layer.Out of scope: the burst limiter itself (already wired, per-route profile).