mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they were written. The per-record siblings a naive or older client loops over — the measurement create, the mood entry, both medication-intake writes, a lab result, a custom-metric entry, a cycle day-log, a vaccination, an allergy, a biomarker and an encounter — had no ceiling at all. That is backwards from the risk: the batch endpoint is the one a well-behaved client uses, and the unlimited path is the one a broken loop finds. All eleven share one generous per-account bucket, `record-write`, at 300 requests a minute. Five writes a second sustained for a full minute is beyond any hand-driven session and any sync burst, and every one of these routes is already wrapped in `withIdempotency`, so an honest retry storm is deduplicated before it is counted — the bucket exists to cut off the pathological loop, not to shape traffic. It keys on the ACTING account, the precedent the delegable medication routes set, so a delegate burns their own allowance rather than locking the record's owner out of their own log. The eleven operations publish the bucket name and the cap instead of the generic refusal, on the shared 429 that already declares `Retry-After` and the rate-limit triple. A test pulls the key and the window out of the query the limiter runs and holds the published sentence to them, so the paragraph cannot drift away from the bucket it describes.
This commit is contained in:
1 parent
040b430e73
commit
ab6c1cd917
35 files changed
+804
-358
No files matched your search
@@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -84,7 +86,15 @@ async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
// A READ or WRITE delegate reaches neither: the destination is not on their
|
||||
// Settings list, and this line refuses them with `sharing.access.denied`
|
||||
// regardless.
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
const { user, actor } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -95,7 +96,15 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetric: { findFirst: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
@@ -105,6 +106,14 @@ async function postCustomMetricEntry(
|
||||
// tracked value adds it back in the same diff: the argument for admitting it
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
@@ -94,11 +95,19 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -154,7 +155,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
|
||||
|
||||
async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
@@ -148,7 +149,15 @@ async function postLabResult(request: NextRequest) {
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -12,6 +12,17 @@ const SELF = {
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
@@ -801,6 +802,14 @@ async function postMeasurement(request: NextRequest) {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
@@ -58,6 +59,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
"medications",
|
||||
);
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
// v1.36.1 follow-up — the sibling route `POST /api/medications/intake`
|
||||
// refuses a delegate changing a dose the owner already recorded. This route
|
||||
// reaches the same rows through the slot upsert, so without the same rule it
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -203,6 +204,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// notification at the end of this handler.
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,17 @@ const txClient = {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
@@ -105,6 +106,14 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// the `externalId` refusal below.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -112,7 +113,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
|
||||
|
||||
async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
const { user, actor } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
Reference in new issue
Block a user