// 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>; /** * 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 => 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 } });