fix(api): put a ceiling on editing a managed record's identity

`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.

The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:24:50 +02:00
1 parent c673255355
commit 0c46df1413
4 files changed
+54 -9

No files matched your search

+10 -3
View File
@@ -18487,8 +18487,9 @@ paths:
description: "Edit the record after it exists — the name that was a placeholder, the timezone that moved with the
household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any
active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion
carry, because the three acts mint, change and end an account that can never prove anything about itself.
Answers with the whole record, so a client re-renders from the server's version rather than from what it sent."
carry, because the three acts mint, change and end an account that can never prove anything about itself. Ten an
hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the
server's version rather than from what it sent."
parameters:
- in: path
name: id
@@ -18522,7 +18523,13 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
"429":
description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch
on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
delete:
tags:
- Account sharing
@@ -19,6 +19,7 @@ import {
updateManagedProfile,
} from "@/lib/managed-profiles/lifecycle";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { updateManagedProfileSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
@@ -70,6 +71,19 @@ export const GET = apiHandler(
},
);
/**
* The ceiling the create sibling carries, on the act that changes what it
* created. Ten an hour per caller: a person cannot mistype their way into ten
* edits of a record's identity, and the number matching creation and guardian
* invitation keeps one family answering one way.
*
* The GET beside it stays unlimited, matching the guardian roster read next to
* it: both are cheap identity reads a Guardian may already make, and neither
* family sibling has ever carried a read bucket.
*/
const UPDATE_LIMIT = 10;
const UPDATE_WINDOW_MS = 60 * 60 * 1000;
/**
* Change a managed record's identity.
*
@@ -100,6 +114,22 @@ export const GET = apiHandler(
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
// Before the id is read, exactly as the create sibling does it. The cheap
// refusal has to come first here for a second reason: `updateManagedProfile`
// opens a transaction and takes an advisory lock on the id it was HANDED
// before it decides the caller is not a Guardian of it, so an unthrottled
// caller can name any id they like and make the database hold a lock about
// it. Same ceiling as creating a record and inviting a Guardian — the three
// acts are one family and answering the same abuse differently is the
// asymmetry nobody notices until it is used.
const rateLimit = await checkRateLimit(
`managed-profile:update:${user.id}`,
UPDATE_LIMIT,
UPDATE_WINDOW_MS,
);
if (!rateLimit.allowed) {
return apiError("Too many profile edits, try again later", 429);
}
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
@@ -366,16 +366,19 @@ export function managedProfileEditPatch(
/**
* The message for a refused edit.
*
* The same four arms creation carries, minus the rate limit it does not have,
* and with the same reasoning: the step-up arm is a gate rather than a failure
* and must not read as one, and a 422 is a disagreement between this form's
* bounds and the route's rather than something the person typed — the
* multi-issue envelope's `details.issues` never reach the browser.
* The arms creation carries plus the one it does not need, and with the same
* reasoning: the step-up arm is a gate rather than a failure and must not read
* as one, a 429 is a wait rather than a failure and reuses creation's own line,
* and a 422 is a disagreement between this form's bounds and the route's rather
* than something the person typed — the multi-issue envelope's `details.issues`
* never reach the browser. The 404 is this route's alone: the record can be
* ended by another Guardian while the form sits open.
*/
export function editManagedProfileErrorKey(err: unknown): string {
if (!(err instanceof ApiError)) return "recordSharing.managed.errorOffline";
if (err.status === 401) return stepUpErrorKey(err);
if (err.status === 422) return "recordSharing.managed.errorInvalid";
if (err.status === 429) return "recordSharing.managed.errorRateLimit";
if (err.status === 404) return "recordSharing.managed.editErrorGone";
return "recordSharing.managed.editErrorFailed";
}
+6 -1
View File
@@ -759,7 +759,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
summary: "Change a managed record's identity",
description:
"Edit the record after it exists — the name that was a placeholder, the timezone that moved with the household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion carry, because the three acts mint, change and end an account that can never prove anything about itself. Answers with the whole record, so a client re-renders from the server's version rather than from what it sent.",
"Edit the record after it exists — the name that was a placeholder, the timezone that moved with the household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion carry, because the three acts mint, change and end an account that can never prove anything about itself. Ten an hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the server's version rather than from what it sent.",
requestParams: {
path: z.object({ id: z.string() }),
},
@@ -789,6 +789,11 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
delete: {