Files
shop/server/utils/customerUpdate.ts
libretech bfcbbfac39 fix(orders): forward only the checkout's fields on an order update
PUT /api/orders/:uuid forwarded the browser's body to the CMS unchanged. It
now forwards { data } with only the seven fields of the checkout's steps:
email, acceptedTermsAndConditionsAt, invoiceAddress, deliveryAddress,
invoiceAddressStructured, deliveryAddressStructured and delivery. Any other
field, a field beside data, or a body of another shape is answered 400
"Invalid order update" with the CMS's error format, without calling the
CMS. The values are left to the CMS, which checks them and stays the
authority; this is defence in depth.

pickCustomerUpdate (server/utils/customerUpdate.ts) is pure and tested with
the exact payloads of steps 1 and 2 and with every server-only attribute of
the order.

Refs libretech/mp#71
2026-10-09 02:32:20 +02:00

80 lines
3.5 KiB
TypeScript

// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else.
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth:
// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer.
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts.
import type { ShopError } from "./cmsError";
/** The order fields a customer may set, in the order of the checkout's steps. */
export const CUSTOMER_UPDATE_FIELDS = [
"email",
"acceptedTermsAndConditionsAt",
"invoiceAddress",
"deliveryAddress",
"invoiceAddressStructured",
"deliveryAddressStructured",
"delivery"
] as const;
export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number];
/** The fields to forward, with the values the browser sent: the CMS checks them. */
export type CustomerUpdate = Partial<Record<CustomerUpdateField, unknown>>;
/**
* unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data.
* errors: one message per rejected field or malformed part, in the CMS's format "name: reason".
*/
export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] };
export const INVALID_ORDER_UPDATE = "Invalid order update";
// A field name is chosen by the client and ends up in the answer, so a long one is cut.
const MAX_NAME_LENGTH = 64;
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key);
const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key);
/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */
export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => {
if (!isObject(body)) {
return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] };
}
const unknown: string[] = [];
const errors: string[] = [];
for (const key of Object.keys(body)) {
if (key === "data") continue;
unknown.push(fieldName(key));
errors.push(`${fieldName(key)}: not accepted, the fields belong in data`);
}
const fields = body.data;
const data: CustomerUpdate = {};
if (!isObject(fields)) {
errors.push(fields === undefined ? "data: missing" : "data: must be an object");
} else {
for (const key of Object.keys(fields)) {
if (isCustomerUpdateField(key)) {
data[key] = fields[key];
} else {
unknown.push(`data.${fieldName(key)}`);
errors.push(`data.${fieldName(key)}: not a field the customer may set`);
}
}
}
return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data };
};
/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */
export const invalidOrderUpdate = (errors: string[]): ShopError => ({
statusCode: 400,
statusMessage: INVALID_ORDER_UPDATE,
data: { message: INVALID_ORDER_UPDATE, errors }
});