diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b399df72e..a76c8db4c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index 6d4deeb12..b713e7ad1 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -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, { diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx index 5d287647d..2245f61c3 100644 --- a/src/components/settings/access/managed-profile-edit-form.tsx +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -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"; } diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 07f487963..6544917ce 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -759,7 +759,7 @@ export const accountSharingPaths: NonNullable = { 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 = { "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: {