Skip to content

Commit 3cc7248

Browse files
committed
refactor(webapp): generalize admin rate-limit section for reuse
1 parent 90cf9d0 commit 3cc7248

4 files changed

Lines changed: 116 additions & 55 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { prisma } from "~/db.server";
2+
import { env } from "~/env.server";
3+
import { logger } from "~/services/logger.server";
4+
import { type Duration } from "~/services/rateLimiter.server";
5+
import { API_RATE_LIMIT_INTENT } from "./ApiRateLimitSection";
6+
import {
7+
handleRateLimitAction,
8+
resolveEffectiveRateLimit,
9+
type RateLimitActionResult,
10+
type RateLimitDomain,
11+
} from "./RateLimitSection.server";
12+
import type { EffectiveRateLimit } from "./RateLimitSection";
13+
14+
export const apiRateLimitDomain: RateLimitDomain = {
15+
intent: API_RATE_LIMIT_INTENT,
16+
systemDefault: () => ({
17+
type: "tokenBucket",
18+
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
19+
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
20+
maxTokens: env.API_RATE_LIMIT_MAX,
21+
}),
22+
apply: async (orgId, next, adminUserId) => {
23+
const existing = await prisma.organization.findFirst({
24+
where: { id: orgId },
25+
select: { apiRateLimiterConfig: true },
26+
});
27+
if (!existing) {
28+
throw new Response(null, { status: 404 });
29+
}
30+
await prisma.organization.update({
31+
where: { id: orgId },
32+
data: { apiRateLimiterConfig: next as any },
33+
});
34+
logger.info("admin.backOffice.apiRateLimit", {
35+
adminUserId,
36+
orgId,
37+
previous: existing.apiRateLimiterConfig,
38+
next,
39+
});
40+
},
41+
};
42+
43+
export function resolveEffectiveApiRateLimit(
44+
override: unknown
45+
): EffectiveRateLimit {
46+
return resolveEffectiveRateLimit(override, apiRateLimitDomain);
47+
}
48+
49+
export function handleApiRateLimitAction(
50+
formData: FormData,
51+
orgId: string,
52+
adminUserId: string
53+
): Promise<RateLimitActionResult> {
54+
return handleRateLimitAction(formData, orgId, adminUserId, apiRateLimitDomain);
55+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import {
2+
RateLimitSection,
3+
type EffectiveRateLimit,
4+
} from "./RateLimitSection";
5+
6+
export const API_RATE_LIMIT_INTENT = "set-rate-limit";
7+
export const API_RATE_LIMIT_SAVED_VALUE = "rate-limit";
8+
9+
type FieldErrors = Record<string, string[] | undefined> | null;
10+
11+
type Props = {
12+
effective: EffectiveRateLimit;
13+
errors: FieldErrors;
14+
savedJustNow: boolean;
15+
isSubmitting: boolean;
16+
};
17+
18+
export function ApiRateLimitSection(props: Props) {
19+
return (
20+
<RateLimitSection
21+
title="API rate limit"
22+
intent={API_RATE_LIMIT_INTENT}
23+
{...props}
24+
/>
25+
);
26+
}
Lines changed: 29 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,66 @@
11
import { z } from "zod";
2-
import { prisma } from "~/db.server";
3-
import { env } from "~/env.server";
42
import {
53
RateLimitTokenBucketConfig,
64
RateLimiterConfig,
75
} from "~/services/authorizationRateLimitMiddleware.server";
8-
import { logger } from "~/services/logger.server";
9-
import { type Duration } from "~/services/rateLimiter.server";
106
import {
117
parseDurationToMs,
12-
RATE_LIMIT_INTENT,
138
type EffectiveRateLimit,
149
} from "./RateLimitSection";
1510

16-
function systemDefaultRateLimit() {
17-
return {
18-
type: "tokenBucket" as const,
19-
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
20-
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
21-
maxTokens: env.API_RATE_LIMIT_MAX,
22-
};
23-
}
11+
export type RateLimitDomain = {
12+
intent: string;
13+
systemDefault: () => RateLimiterConfig;
14+
apply: (
15+
orgId: string,
16+
next: RateLimitTokenBucketConfig,
17+
adminUserId: string
18+
) => Promise<void>;
19+
};
2420

2521
export function resolveEffectiveRateLimit(
26-
override: unknown
22+
override: unknown,
23+
domain: RateLimitDomain
2724
): EffectiveRateLimit {
2825
if (override == null) {
29-
return { source: "default", config: systemDefaultRateLimit() };
26+
return { source: "default", config: domain.systemDefault() };
3027
}
3128
const parsed = RateLimiterConfig.safeParse(override);
3229
if (parsed.success) {
3330
return { source: "override", config: parsed.data };
3431
}
3532
// Column holds malformed JSON — fall back silently. Admin must investigate
3633
// at the DB level; this UI can't recover it.
37-
return { source: "default", config: systemDefaultRateLimit() };
34+
return { source: "default", config: domain.systemDefault() };
3835
}
3936

40-
const SetRateLimitSchema = z.object({
41-
intent: z.literal(RATE_LIMIT_INTENT),
42-
refillRate: z.coerce.number().int().min(1),
43-
interval: z
44-
.string()
45-
.trim()
46-
.refine((v) => parseDurationToMs(v) > 0, {
47-
message: "Must be a duration like 10s, 1m, 500ms.",
48-
}),
49-
maxTokens: z.coerce.number().int().min(1),
50-
});
51-
5237
export type RateLimitActionResult =
5338
| { ok: true }
5439
| { ok: false; errors: Record<string, string[] | undefined> };
5540

5641
export async function handleRateLimitAction(
5742
formData: FormData,
5843
orgId: string,
59-
adminUserId: string
44+
adminUserId: string,
45+
domain: RateLimitDomain
6046
): Promise<RateLimitActionResult> {
61-
const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
47+
const schema = z.object({
48+
intent: z.literal(domain.intent),
49+
refillRate: z.coerce.number().int().min(1),
50+
interval: z
51+
.string()
52+
.trim()
53+
.refine((v) => parseDurationToMs(v) > 0, {
54+
message: "Must be a duration like 10s, 1m, 500ms.",
55+
}),
56+
maxTokens: z.coerce.number().int().min(1),
57+
});
58+
59+
const submission = schema.safeParse(Object.fromEntries(formData));
6260
if (!submission.success) {
6361
return { ok: false, errors: submission.error.flatten().fieldErrors };
6462
}
6563

66-
const existing = await prisma.organization.findFirst({
67-
where: { id: orgId },
68-
select: { apiRateLimiterConfig: true },
69-
});
70-
if (!existing) {
71-
throw new Response(null, { status: 404 });
72-
}
73-
7464
const built = RateLimitTokenBucketConfig.safeParse({
7565
type: "tokenBucket",
7666
refillRate: submission.data.refillRate,
@@ -81,17 +71,6 @@ export async function handleRateLimitAction(
8171
return { ok: false, errors: built.error.flatten().fieldErrors };
8272
}
8373

84-
await prisma.organization.update({
85-
where: { id: orgId },
86-
data: { apiRateLimiterConfig: built.data as any },
87-
});
88-
89-
logger.info("admin.backOffice.rateLimit", {
90-
adminUserId,
91-
orgId,
92-
previous: existing.apiRateLimiterConfig,
93-
next: built.data,
94-
});
95-
74+
await domain.apply(orgId, built.data, adminUserId);
9675
return { ok: true };
9776
}

apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import { Label } from "~/components/primitives/Label";
88
import { Paragraph } from "~/components/primitives/Paragraph";
99
import * as Property from "~/components/primitives/PropertyTable";
1010

11-
export const RATE_LIMIT_INTENT = "set-rate-limit";
12-
export const RATE_LIMIT_SAVED_VALUE = "rate-limit";
13-
1411
// Local shape mirrors the server-side discriminated union just enough for this
1512
// view. Decoupled from the .server module so the component stays client-safe.
1613
export type RateLimitConfig =
@@ -34,13 +31,17 @@ export type EffectiveRateLimit = {
3431
type FieldErrors = Record<string, string[] | undefined> | null;
3532

3633
type Props = {
34+
title: string;
35+
intent: string;
3736
effective: EffectiveRateLimit;
3837
errors: FieldErrors;
3938
savedJustNow: boolean;
4039
isSubmitting: boolean;
4140
};
4241

4342
export function RateLimitSection({
43+
title,
44+
intent,
4445
effective,
4546
errors,
4647
savedJustNow,
@@ -96,7 +97,7 @@ export function RateLimitSection({
9697
return (
9798
<section className="flex flex-col gap-3 rounded-md border border-charcoal-700 bg-charcoal-800 p-4">
9899
<div className="flex items-center justify-between">
99-
<Header2>API rate limit</Header2>
100+
<Header2>{title}</Header2>
100101
{!isEditing && (
101102
<Button
102103
variant="tertiary/small"
@@ -175,7 +176,7 @@ export function RateLimitSection({
175176
</>
176177
) : (
177178
<Form method="post" className="flex flex-col gap-3 pt-2">
178-
<input type="hidden" name="intent" value={RATE_LIMIT_INTENT} />
179+
<input type="hidden" name="intent" value={intent} />
179180

180181
<div className="flex flex-col gap-1">
181182
<Label>Refill rate (tokens per interval)</Label>

0 commit comments

Comments
 (0)