53 lines
1.5 KiB
TypeScript
53 lines
1.5 KiB
TypeScript
import { env } from "cloudflare:workers";
|
|
import { AppError } from "@/server/lib/errors";
|
|
|
|
// Daily caps on invitation *emails*. The org plugin's invitationLimit bounds
|
|
// pending rows (20), but re-sends to an already-invited address are unbounded
|
|
// there — this is the bound on actual sends. KV is the only counter that
|
|
// holds across Workers isolates; the fixed UTC-day window keys expire on
|
|
// their own. KV writes race under concurrency, so treat these as abuse
|
|
// bounds, not exact quotas.
|
|
const PER_ADDRESS_DAILY_LIMIT = 5;
|
|
const PER_ORG_DAILY_LIMIT = 50;
|
|
const WINDOW_TTL_SECONDS = 60 * 60 * 24;
|
|
|
|
async function bumpDailyCounter(key: string, limit: number) {
|
|
const count = Number((await env.KV.get(key)) ?? "0");
|
|
if (count >= limit) return false;
|
|
await env.KV.put(key, String(count + 1), {
|
|
expirationTtl: WINDOW_TTL_SECONDS,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
export async function consumeInvitationSendBudget(
|
|
organizationId: string,
|
|
email: string,
|
|
) {
|
|
const day = new Date().toISOString().slice(0, 10);
|
|
const address = email.trim().toLowerCase();
|
|
|
|
if (
|
|
!(await bumpDailyCounter(
|
|
`invite-sends:${organizationId}:${day}`,
|
|
PER_ORG_DAILY_LIMIT,
|
|
))
|
|
) {
|
|
throw new AppError(
|
|
"RATE_LIMITED",
|
|
"This organization has reached its daily invitation limit.",
|
|
);
|
|
}
|
|
if (
|
|
!(await bumpDailyCounter(
|
|
`invite-sends:${organizationId}:${address}:${day}`,
|
|
PER_ADDRESS_DAILY_LIMIT,
|
|
))
|
|
) {
|
|
throw new AppError(
|
|
"RATE_LIMITED",
|
|
"This address has already received several invitations today.",
|
|
);
|
|
}
|
|
}
|