metatron-open-seo/src/server/auth/invitation-send-limit.ts

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.",
);
}
}