wacrm/src/app/api/account/invitations/[id]/route.ts
Arnas Donauskas 01715c7d6d
feat(api): per-user rate limits on admin account endpoints
Addresses the L1 finding from the final security review. The six
admin-class write endpoints under /api/account had no rate
limits, so a compromised admin session (or a script run in a
loop) could spam invite creation / role flips / member removes
fast enough to flood the DB or audit logs.

Adds a new `RATE_LIMITS.adminAction` bucket — 30/min/user — and
wires it into:

  - POST   /api/account/invitations          → admin:inviteCreate
  - DELETE /api/account/invitations/[id]     → admin:inviteRevoke
  - PATCH  /api/account/members/[userId]     → admin:memberRole
  - DELETE /api/account/members/[userId]     → admin:memberRemove
  - POST   /api/account/transfer-ownership   → admin:transferOwnership
  - PATCH  /api/account                      → admin:rename

Each endpoint keys its own bucket so spamming one doesn't starve
another. 30/min is comfortably above any realistic human pace
(the Members tab is a clicks-only UI; a legit admin would
struggle to hit ten of these per minute by hand) while still
bounding accidental abuse.

The bucket joins the existing send / broadcast / react /
invitationPeek / invitationRedeem buckets in `RATE_LIMITS` —
same in-memory fixed-window limiter, same `rateLimitResponse`
helper for the 429 shape.

Severity in the final review: LOW. The admin-trust model means
this isn't blocking — but the cost of adding it is six
near-identical 4-line additions and zero net risk, so worth
landing for hardening.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:05:49 +03:00

74 lines
2.3 KiB
TypeScript

// ============================================================
// DELETE /api/account/invitations/[id]
//
// Admin+. Revokes a pending invitation by id. RLS on
// `account_invitations` already restricts the DELETE to admins
// of the inviting account; we lean on it and skip the explicit
// ownership check.
//
// We intentionally delete the row outright rather than soft-
// deleting (a "revoked_at" flag). Once revoked, an invite is
// dead forever — there's no UX where a former invite should be
// listed; the plaintext token is gone too. Hard delete keeps
// the table small.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireRole("admin");
const limit = checkRateLimit(
`admin:inviteRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction,
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// No `eq('account_id', ctx.accountId)` — the RLS policy
// (`is_account_member(account_id, 'admin')`) already scopes
// the DELETE to invites in the caller's account. Adding the
// filter would be redundant; omitting it surfaces a
// cross-account attempt as a silent 0-row delete (which is
// exactly what we want for a revocation endpoint).
const { error, count } = await ctx.supabase
.from("account_invitations")
.delete({ count: "exact" })
.eq("id", id);
if (error) {
console.error("[DELETE /api/account/invitations/[id]] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
if (count === 0) {
// Either the id doesn't exist or RLS hid it (different
// account). 404 either way — surfacing "exists but not
// yours" would leak existence.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}