Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.
Wire-ins (the two narrow surfaces called out in the v1 spec):
1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
IP per 60s. IP comes from `next/headers` (x-forwarded-for first
entry, then x-real-ip); when headers() throws or returns nothing we
fall back to keying on "unknown" in prod and skipping the limiter
entirely in dev so a local test loop doesn't lock itself out. On a
trip we `console.warn` and return null — the standard Auth.js
"auth failed" signal — without consulting the DB.
2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
invite-creates per inviter per hour. Keyed by inviter id (not
workspace) so a multi-workspace admin can't multiply their
allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
retry-after seconds count baked into the message.
Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
/**
|
|
* Pure, allocation-cheap fixed-window counter used by the in-process rate
|
|
* limiter in `apps/web/server/lib/rate-limit.ts`. Lives in `@tasks/shared`
|
|
* so the algorithm can be unit-tested in isolation — `apps/web` has no
|
|
* vitest harness yet (see Task-bootstrap-vitest-for-apps-web).
|
|
*
|
|
* The shape is a fixed window, not a leaky bucket: a single counter that
|
|
* resets at `resetAt`. This is the simplest correct primitive for "N
|
|
* attempts per window per key" guardrails and matches the limits described
|
|
* in the rate-limit task spec (5/min for sign-in, 10/hr for invites). A
|
|
* sliding-log or leaky-bucket would be more accurate around window
|
|
* boundaries, but the cost is more state per key for no real abuse-control
|
|
* win at these limits.
|
|
*
|
|
* The function is pure: callers pass in the current state (or `undefined`
|
|
* to start fresh) plus the wall clock as `now`. The returned `state` field
|
|
* is the value the caller should store back into its key→state map. This
|
|
* keeps the bucket trivially testable with a fake clock and avoids any
|
|
* hidden global mutable state inside `@tasks/shared`.
|
|
*/
|
|
|
|
export interface TokenBucketState {
|
|
/** Attempts consumed within the current window. */
|
|
count: number;
|
|
/** Epoch milliseconds at which the current window expires. */
|
|
resetAt: number;
|
|
}
|
|
|
|
export interface TokenBucketOptions {
|
|
/** Max allowed attempts per window. Must be >= 1. */
|
|
limit: number;
|
|
/** Window length in milliseconds. Must be >= 1. */
|
|
windowMs: number;
|
|
}
|
|
|
|
export type TokenBucketResult =
|
|
| {
|
|
allowed: true;
|
|
remaining: number;
|
|
resetAt: number;
|
|
state: TokenBucketState;
|
|
}
|
|
| {
|
|
allowed: false;
|
|
remaining: 0;
|
|
resetAt: number;
|
|
retryAfterMs: number;
|
|
state: TokenBucketState;
|
|
};
|
|
|
|
/**
|
|
* Charge one attempt against `state` for the bucket described by `opts`.
|
|
*
|
|
* - If `state` is undefined or its window has expired (`now >= resetAt`),
|
|
* a fresh window starts: count = 1, resetAt = now + windowMs.
|
|
* - Otherwise the count is incremented. If the post-increment count
|
|
* exceeds `limit`, the call is denied; the returned `state` does NOT
|
|
* include the over-limit increment, so a flood of denied calls cannot
|
|
* inflate the counter past `limit + 1`.
|
|
*
|
|
* Returning the next-state alongside the decision means the call site
|
|
* doesn't need to special-case "denied" vs "allowed" when writing back —
|
|
* `result.state` is always the value to persist.
|
|
*/
|
|
export function consumeTokenBucket(
|
|
state: TokenBucketState | undefined,
|
|
now: number,
|
|
opts: TokenBucketOptions,
|
|
): TokenBucketResult {
|
|
if (!Number.isFinite(opts.limit) || opts.limit < 1) {
|
|
throw new Error("token-bucket: limit must be >= 1");
|
|
}
|
|
if (!Number.isFinite(opts.windowMs) || opts.windowMs < 1) {
|
|
throw new Error("token-bucket: windowMs must be >= 1");
|
|
}
|
|
|
|
const windowExpired = !state || now >= state.resetAt;
|
|
if (windowExpired) {
|
|
const next: TokenBucketState = { count: 1, resetAt: now + opts.windowMs };
|
|
return {
|
|
allowed: true,
|
|
remaining: opts.limit - 1,
|
|
resetAt: next.resetAt,
|
|
state: next,
|
|
};
|
|
}
|
|
|
|
const nextCount = state.count + 1;
|
|
if (nextCount > opts.limit) {
|
|
// Keep count pegged at limit so repeated denied calls don't inflate
|
|
// the counter unboundedly. retryAfterMs shrinks as time advances
|
|
// within the same denied window.
|
|
const pegged: TokenBucketState = { count: opts.limit, resetAt: state.resetAt };
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetAt: state.resetAt,
|
|
retryAfterMs: Math.max(0, state.resetAt - now),
|
|
state: pegged,
|
|
};
|
|
}
|
|
|
|
const next: TokenBucketState = { count: nextCount, resetAt: state.resetAt };
|
|
return {
|
|
allowed: true,
|
|
remaining: opts.limit - nextCount,
|
|
resetAt: next.resetAt,
|
|
state: next,
|
|
};
|
|
}
|