Make the reset-every-threshold delete ask first

DELETE /api/user/thresholds without ?metric= erased the account's whole
override set on an omitted query parameter alone: no confirmation, no dry
run, and no rate limit of its own while the PUT beside it had one.

The wide form now wants { "confirm": "RESET_THRESHOLDS" } in the body — the
same typed-confirmation shape /api/settings/account and /api/settings/data
already use — and refuses 422 without writing anything. The ?metric= form
is untouched: it resets one band the person can retype, and the targets
sheet fires it in a loop. Both forms now run the PUT's 30 / 5 min limit.

The settings card's "reset all" control already opened a confirm dialog;
it now sends that answer to the server too. Its token is compared against
the route's declaration in the tests, so the two ends cannot drift apart
into a permanent 422.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 16:55:59 +02:00
1 parent c4544556f7
commit fffdc83ca4
5 files changed
+282 -15

No files matched your search

+38 -3
View File
@@ -653,6 +653,27 @@ const thresholdsOverridesResponse = z
.object({ overrides: thresholdOverrideMap })
.meta({ id: "ThresholdsOverridesResponse" });
/**
* The typed confirmation the parameterless DELETE demands.
*
* Published because it is the difference between "reset one band" and "erase
* every band the account has tuned", and a client that learned the endpoint
* from the contract alone would otherwise discover the requirement as a 422.
*/
const thresholdsResetAllRequest = z
.object({
confirm: z
.literal("RESET_THRESHOLDS")
.describe(
"Required when `metric` is omitted. Compared exactly; any other value is refused.",
),
})
.meta({
id: "ThresholdsResetAllRequest",
description:
"Confirmation body for the reset-everything form. Send no body at all when resetting a single metric with `?metric=`.",
});
const thresholdsUpdateRequest = thresholdsUpdateSchema.meta({
id: "ThresholdsUpdateRequest",
description:
@@ -1716,17 +1737,23 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Analytics"],
summary: "Reset one threshold override, or all of them",
description:
"With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account. There is no confirmation step and no dry run, so a client that means to reset one metric must send the parameter.\n\n" +
"Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own.",
'With `?metric=` it drops that metric\'s override; WITHOUT the parameter it drops EVERY override on the account. The wide form is not reachable by omitting the parameter alone — it requires `{ "confirm": "RESET_THRESHOLDS" }` in the body, the same typed-confirmation shape account deletion and the data reset use. The `?metric=` form takes no body and is unchanged.\n\n' +
"Audit-logged with the before and after maps. Rate-limited 30 / 5 min per user, matching the PUT.",
requestParams: {
query: z.object({
metric: thresholdMetricEnum
.optional()
.describe(
"The single metric to reset. Omit to reset every override on the account.",
"The single metric to reset. Omit to reset every override on the account — which then requires the confirmation body.",
),
}),
},
requestBody: {
required: false,
content: {
"application/json": { schema: thresholdsResetAllRequest },
},
},
responses: {
"200": {
description: "The remaining override map.",
@@ -1745,6 +1772,14 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
// After `stdResponses` on purpose: the generic 422 there would
// otherwise overwrite this one and the confirmation contract would
// vanish from the published operation.
"422": {
description:
'`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.',
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},