import { WidgetError } from "./errors.js"; import type { StructuredUiRequest, SubmitPlaybookFieldsResponse, } from "./playbook-contracts.js"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; /** Validates a Playbook UI instruction before it reaches the Shadow DOM. */ export function parseStructuredUiRequest(value: unknown): StructuredUiRequest { if ( !isRecord(value) || !isUuid(value.runId) || typeof value.title !== "string" ) { throw new WidgetError("invalid_response"); } switch (value.kind) { case "stay_context": return parseStayContextRequest(value); case "contact_details": return parseContactDetailsRequest(value); case "handoff_choice": return parseHandoffChoiceRequest(value); case "handoff_result": return parseHandoffResultRequest(value); default: throw new WidgetError("invalid_response"); } } export function parseSubmitPlaybookFieldsResponse( value: unknown, ): SubmitPlaybookFieldsResponse { if (!isRecord(value)) throw new WidgetError("invalid_response"); return { nextUiRequest: value.nextUiRequest === null ? null : parseStructuredUiRequest(value.nextUiRequest), }; } function parseStayContextRequest( value: Record, ): Extract { if (typeof value.submitLabel !== "string" || !isRecord(value.initialValues)) { throw new WidgetError("invalid_response"); } const initialValues = value.initialValues; return { kind: "stay_context", runId: value.runId as string, title: value.title as string, submitLabel: value.submitLabel, initialValues: { ...optionalString(initialValues, "checkIn"), ...optionalString(initialValues, "checkOut"), ...optionalNumber(initialValues, "adults"), ...optionalNumber(initialValues, "children"), ...optionalNumber(initialValues, "rooms"), ...optionalString(initialValues, "notes"), }, }; } function parseContactDetailsRequest( value: Record, ): Extract { if (typeof value.submitLabel !== "string") { throw new WidgetError("invalid_response"); } if (typeof value.consentLabel === "string") { return { kind: "contact_details", runId: value.runId as string, title: value.title as string, submitLabel: value.submitLabel, consentLabel: value.consentLabel, }; } if ( typeof value.firstNameLabel !== "string" || typeof value.lastNameLabel !== "string" || typeof value.emailLabel !== "string" || typeof value.phoneLabel !== "string" || typeof value.privacyAcknowledgementLabel !== "string" || typeof value.privacyNoticeLabel !== "string" || !isHttpUrl(value.privacyNoticeUrl) || typeof value.declineLabel !== "string" ) { throw new WidgetError("invalid_response"); } return { kind: "contact_details", runId: value.runId as string, title: value.title as string, submitLabel: value.submitLabel, firstNameLabel: value.firstNameLabel, lastNameLabel: value.lastNameLabel, emailLabel: value.emailLabel, phoneLabel: value.phoneLabel, privacyAcknowledgementLabel: value.privacyAcknowledgementLabel, privacyNoticeLabel: value.privacyNoticeLabel, privacyNoticeUrl: value.privacyNoticeUrl, declineLabel: value.declineLabel, }; } function parseHandoffChoiceRequest( value: Record, ): Extract { if (!Array.isArray(value.options) || value.options.length === 0) { throw new WidgetError("invalid_response"); } const options = value.options.map((option) => { if ( !isRecord(option) || (option.value !== "booking_engine" && option.value !== "staff") || typeof option.label !== "string" ) { throw new WidgetError("invalid_response"); } if (option.value === "staff") { return { value: "staff" as const, label: option.label }; } return { value: "booking_engine" as const, label: option.label }; }); return { kind: "handoff_choice", runId: value.runId as string, title: value.title as string, options, }; } function parseHandoffResultRequest( value: Record, ): Extract { const isDestinationValid = value.destinationUrl === null || isHttpUrl(value.destinationUrl); if ( typeof value.message !== "string" || !isDestinationValid || (value.destinationLabel !== null && typeof value.destinationLabel !== "string") ) { throw new WidgetError("invalid_response"); } return { kind: "handoff_result", runId: value.runId as string, title: value.title as string, message: value.message, destinationUrl: value.destinationUrl as string | null, destinationLabel: value.destinationLabel, }; } function optionalString( value: Record, key: string, ): Record { const field = value[key]; if (field === undefined) return {}; if (typeof field !== "string") throw new WidgetError("invalid_response"); return { [key]: field }; } function optionalNumber( value: Record, key: string, ): Record { const field = value[key]; if (field === undefined) return {}; if (typeof field !== "number" || !Number.isFinite(field)) { throw new WidgetError("invalid_response"); } return { [key]: field }; } function isHttpUrl(value: unknown): value is string { if (typeof value !== "string") return false; try { return ["http:", "https:"].includes(new URL(value).protocol); } catch { return false; } } function isUuid(value: unknown): value is string { return typeof value === "string" && UUID_PATTERN.test(value); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }